Org lead correction: every Rust host already updates the server and re-installs Oxide together. Presenting that as something an operator would be caught by talks down to the audience. Keeps the one narrow consequence that is actually ours: app_update leaves Oxide.Core.dll and the rest in place, so a phase-18 doctor check that tests for oxide/ or for Oxide's assemblies passes on a server that is mid-routine. Compare Assembly-CSharp.dll against the Oxide build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1318 lines
87 KiB
Markdown
1318 lines
87 KiB
Markdown
# `module-rust` — the plan
|
||
|
||
**Status:** approved in outline 2026-09-15, not started. **Eighteen decisions of record, no open
|
||
questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and
|
||
engagement catalogues are §9 and §10; §11 is a second pass over `MODULE_API.md` itself.
|
||
|
||
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 | Repo | Mirrors | What it is |
|
||
|---|---|---|---|
|
||
| Website module | [`RunicGateway/Module-Rust`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) | `Module-uo` | Routes, schema fragment, prebuilt client chunk, nav |
|
||
| Sidecar | [`RunicGateway/Rust-Link`](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) | `link` | Owns the game connection and the durable copy |
|
||
| Oxide bridge plugin | [`RunicGateway/Rust-Plugins`](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins) | `servuo-plugins` | C# inside the game, dials out, never blocks |
|
||
| *(event capability)* | — | — | Declarations inside `Module-Rust` ([kit][kit] ch. 5) |
|
||
|
||
All three were created empty on **2026-09-15**. The module is built from
|
||
[`integration-kit/template/`][kit], which CI holds against a pinned core — currently
|
||
`MODULE_API_VERSION` **1.10.0**.
|
||
|
||
**The repository name is not the module id.** `Module-uo` ships a module whose `id` is `uo`; this one
|
||
ships `rust`. §2.1 of the contract requires `id` to equal the directory core loads it from, which is
|
||
`modules/rust/`, and it is the prefix of every table and every mount — so the capitalisation in the
|
||
repository name reaches nothing inside the bundle.
|
||
|
||
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).
|
||
|
||
**Phase 0 exercised all three against the real store and they hold — but it found four rules the
|
||
push path has to obey (§12.2).** The one that would have cost the most:
|
||
**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It
|
||
returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is
|
||
registered by the plugin that declares it, so **every grant naming an unloaded, renamed or
|
||
uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is
|
||
"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent
|
||
partial. The push must check `PermissionExists` (or register the name itself) and report the
|
||
difference as drift rather than assuming a write landed.
|
||
|
||
**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 19,
|
||
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.
|
||
|
||
### R4 — Rust reaches an operator through the existing installer, behind `--game`
|
||
|
||
**Decided 2026-09-15 (org lead).** Not a second binary and not a shared-core refactor: the shipped
|
||
[`installer`](../../installer/PLAN.md) grows a game dimension, `--game servuo|rust`, and keeps one
|
||
release stream, one `doctor`, one `update`, one `uninstall`.
|
||
|
||
The shape of the work is set by where the coupling already is. **The reusable half is already
|
||
game-agnostic** — `src/service.rs` and `src/main.rs` mention ServUO zero times, and `net.rs`,
|
||
`diff.rs`, `paths.rs` and `ui.rs` barely more. **The UO-specific half is concentrated in four files**
|
||
— `install.rs`, `doctor.rs`, `overlay.rs`, `tier.rs` — plus the bundle manifest, where
|
||
`OverlayComponent` and `ServUoCompat` name the game in the schema itself.
|
||
|
||
So the change is: make the bundle's game payload a **variant** rather than an overlay, and
|
||
`ServUoCompat` a per-game compat block. That is a schema change on the published **`bundles`**
|
||
branch, and it is the part to design before touching code.
|
||
|
||
**The Rust payload is much simpler than the UO one**, which is what makes this affordable: no source
|
||
tree to overlay, no `patches/`, no opt-in patch tier — a Rust install is a `.cs` file dropped into
|
||
`oxide/plugins/`, plus the sidecar and its service, which the installer already knows how to do. What
|
||
it gains instead is a **prerequisite check**: is Oxide installed, and is its build current enough.
|
||
That is `doctor`'s shape, not a new concept.
|
||
|
||
The protocol pairing check generalises unchanged. `servuo-plugins/overlay.toml` declares the protocol
|
||
the overlay speaks and the installer refuses to pair a disagreeing sidecar; `rust-plugins` needs the
|
||
same declaration under whatever name the variant gives it, and the refusal is the same refusal.
|
||
|
||
### R5 — Teams come from Rust's first-party clans; the uMod Clans plugin is the richer tier
|
||
|
||
**Decided 2026-09-15 (org lead).** Basic functionality is built on **Rust's own clan system**; the
|
||
third-party **uMod Clans plugin** is an optional layer for a much richer experience, and lands in
|
||
phase 17 rather than phase 9.
|
||
|
||
The distinction matters more than the names suggest, so it is worth stating precisely:
|
||
|
||
- **Rust's first-party clans** are seven hooks in [`HOOKS.md`](HOOKS.md) — `OnClanCreated`,
|
||
`OnClanDisbanded`, `OnClanMemberAdded`, `OnClanMemberKicked`, `OnClanMemberLeft`, plus colour and
|
||
logo — each handing you a `LocalClan`. **All seven are "no return behavior"**, which is exactly
|
||
what a read-only bridge wants: there is nothing to abstain from, so [kit][kit] ch. 4's
|
||
return-`null`-from-every-veto rule has no work to do here. They need no plugin, and the rig is
|
||
already running them (`D:\rust\server\server1\clans.287.db`).
|
||
- **Rust's first-party *Teams* are a different system** — twelve hooks, mostly vetoable — and are the
|
||
transient in-game squad, not the persistent organisation. They are **not** what core's Team
|
||
provider should be fed. Clans is the right choice and this is why.
|
||
- **The uMod Clans plugin's API is not in our mirror.** [`HOOKS.md`](HOOKS.md) is the *game's* 477
|
||
hooks; a plugin's own API is its own documentation. It is reached through `[PluginReference]` and
|
||
is null when absent, making it an R3-shaped dependency that must refuse with a reason.
|
||
|
||
**Reading that plugin's source settled R5 far more firmly than the reasoning above did, and in a
|
||
direction worth stating plainly: for Teams, the plugin is *worse* than first-party, not richer.**
|
||
`Clans` v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes **fifteen `[HookMethod]`s and every one of
|
||
them is a mutation** — `CreateClan`, `JoinClan`, `LeaveClan`, `KickPlayer`, `PromotePlayer`,
|
||
`DemotePlayer`, `DisbandClan`, and the eight alliance verbs. **There is no read API whatsoever**: no
|
||
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`.
|
||
|
||
> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks —
|
||
> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and
|
||
> concluded that the plugin cannot answer core's provider questions at all. **That was a grep
|
||
> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to
|
||
> a search for `CallHook("OnClan…` because the name is a `const` at the call site:
|
||
>
|
||
> ```csharp
|
||
> const string HOOK_NAME = "OnClanMemberJoined";
|
||
> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList);
|
||
> ```
|
||
>
|
||
> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`,
|
||
> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and
|
||
> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer
|
||
> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the
|
||
> exact transitions first-party lacks.
|
||
|
||
**The decision does not change, but its reason does.** First-party remains the Team provider's source
|
||
because it is the system the *game* maintains and every server has it; the plugin is an optional
|
||
install that not every shard will run, and feeding a provider from something optional makes Teams
|
||
conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that
|
||
it should not be the one asked. Feeding the Team provider from first-party clans is still
|
||
**permanent, not a first step**.
|
||
|
||
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
|
||
roster data. That is what phase 17's adapter surfaces, and it sits beside the Team provider rather
|
||
than under it.
|
||
|
||
One route is deliberately not taken: the plugin persists to its own files under `oxide/data/`, and a
|
||
determined integration could read those directly. **That is reaching into another plugin's private
|
||
storage, not using an API** — it breaks without warning on any upstream refactor and is not a
|
||
contract anybody owes us. If phase 17 wants roster data from the plugin, the honest path is an
|
||
upstream pull request adding a read method, not a file reader.
|
||
|
||
### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager
|
||
|
||
**Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's
|
||
and BetterChat's author differs only in the optional tier. All fetched at plan time:
|
||
|
||
| Plugin | Version | Released | Source |
|
||
|---|---|---|---|
|
||
| [Kits](https://umod.org/plugins/rust-kits) | 4.4.9 | 2026-06-04 | `https://umod.org/plugins/Kits.cs` |
|
||
| [Clans](https://umod.org/plugins/clans) | 0.2.10 | 2026-05-06 | `https://umod.org/plugins/Clans.cs` |
|
||
| [PopupNotifications](https://umod.org/plugins/popup-notifications) | 0.2.1 | 2026-08-09 | `https://umod.org/plugins/PopupNotifications.cs` |
|
||
| [ZoneManager](https://umod.org/plugins/zone-manager) (R17) | 3.1.14 | 2026-09-02 | `https://umod.org/plugins/ZoneManager.cs` |
|
||
|
||
Every one has a direct `.cs` download, so phase 0's install step is four `curl`s into
|
||
`oxide/plugins/` and no manual retrieval. **Pull them fresh rather than using the copies staged in
|
||
`Downloads`**, which are an older vintage.
|
||
|
||
**The four base plugins use three different conventions for their callable API**, which is worth
|
||
knowing before someone goes hunting for the wrong one: Kits uses `[HookMethod]`, BetterChat uses
|
||
`API_`-prefixed methods, and ZoneManager uses plain private methods resolved by name. All three are
|
||
reached the same way from our side — `[PluginReference]` plus `Call()` — but only the first is
|
||
greppable as a declared API.
|
||
|
||
`Clans` is listed by uMod as a **Universal** plugin rather than a Rust one — it is written against
|
||
Covalence, and its API takes `IPlayer` rather than `BasePlayer`. That is the portable half of the
|
||
uMod surface, and it is why the same plugin serves several games.
|
||
|
||
**Kits is the opposite of Clans and is a genuinely good dependency.** Twenty-three `[HookMethod]`s,
|
||
including the three things the event work actually needs:
|
||
|
||
- `GiveKit(BasePlayer player, string name)` — the reward action's call;
|
||
- `GetKitNames(List<string>)` / `GetAllKits()` — **the option source** for the authoring form, so an
|
||
operator picks a kit from the live server instead of typing an identifier from memory;
|
||
- `GetKitInfo` / `KitDescription` / `KitImage` / `KitMax` / `KitCooldown` /
|
||
`GetPlayerKitUses` / `GetPlayerKitCooldown` — form metadata and per-player eligibility.
|
||
|
||
It also **raises `OnKitRedeemed(BasePlayer player, string kitName)`**, which the bridge can listen on
|
||
to report a redemption as an ordinary event, whoever triggered it.
|
||
|
||
**Two traps in `GiveKit`, both found by reading it rather than by reasoning about it.**
|
||
|
||
> **R16 moved these off the critical path.** The reward action no longer calls `GiveKit` — it grants
|
||
> the kit's `RequiredPermission` and the player redeems it themselves. Both traps are kept here
|
||
> because the second one is *why* R16 is the better design, and because anything that ever does call
|
||
> `GiveKit` directly — an admin "give this player a kit now" button, say — walks straight into them.
|
||
|
||
**`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The
|
||
first line is `if (!player) return null;`. Everywhere else in this ecosystem a null return means
|
||
*abstain*, so the reflex — treat null as fine — reports a reward as delivered when there was no
|
||
player to deliver it to. The success value is the literal `true`; a refusal is a **message string**,
|
||
which drops straight into chapter 5's `error` field. So the mapping is
|
||
`result is bool ok && ok` for success, a string for a refusal reason, and **`null` is a failure, not
|
||
a success.**
|
||
|
||
**`GiveKit` takes a `BasePlayer`, so the player must be connected.** There is no offline grant in
|
||
this API. An event that rewards participants at two in the morning rewards only whoever is online at
|
||
that moment, silently. The choice this forced was: accept online-only and say so in the action's
|
||
description, or keep a persisted pending-grant queue in the bridge and redeem it on next connect —
|
||
a second at-most-once store with its own idempotency, which is not free.
|
||
|
||
**R16 took a third option and it is the right one: stop granting items.** Grant the entitlement
|
||
instead. An entitlement waits without a queue, because waiting is what an entitlement does. The
|
||
problem was not hard to solve — it was the wrong problem, produced by an action declared around the
|
||
wrong noun.
|
||
|
||
`PopupNotifications` is small and does exactly one thing: `CreatePopupNotification(string message,
|
||
BasePlayer player = null, float duration = 0f)`, where a null player makes it global. That is the
|
||
server-side notification surface, and it needs no more than that.
|
||
|
||
**The one gap to design around:** the seven first-party hooks carry created, disbanded, added,
|
||
kicked and left — but **no promote or leader-changed event**. Core's provider requires
|
||
`getTeamLeaders`, so leadership is read off `LocalClan` at snapshot time rather than tracked from
|
||
transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
|
||
fully event-driven — a small correction to that document, recorded here rather than silently.
|
||
|
||
Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the
|
||
**Clans plugin closes it** — `OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does
|
||
not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven
|
||
leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design
|
||
phase 9's snapshot so phase 17 can sharpen it rather than replace it.
|
||
|
||
### R7 — the notifications and engagement set ships in v1
|
||
|
||
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
|
||
`registerAudiences` and `registerEngagementSeeds` are all in the first release, plus
|
||
`registerAnnounceLeg` and `registerPostHook`.
|
||
|
||
They are a **matched set**, which is why they are one decision and one phase: a trigger declares the
|
||
payload contract and the widest audience a rule on it may ever be given, an audience resolves *people*
|
||
over module data, and seeds ship the bodies and the rules that use them. Three rules from the kit
|
||
that this phase lives or dies on:
|
||
|
||
- **`ceiling` is required, has no default, and is not a ladder.** A `staff` ceiling does **not** permit
|
||
`owner`, because "one person" for a cheat-detection event is *the player it was detected on*. Fewer
|
||
people is not less exposure.
|
||
- **`subjectKey` must name one of your declared variables** — it is what the cooldown keys on. Core
|
||
refuses the module at boot if it names nothing, which is the good failure; the bad one it prevents
|
||
is every subject sharing `undefined`.
|
||
- **A rule group is offered once, per group key.** A rule appended to an existing group reaches
|
||
**fresh installs only** — so a rule that must reach existing deployments takes a new group key.
|
||
|
||
And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a
|
||
module that emitted "the server is still up" as news.
|
||
|
||
### R8 — multi-server from the start
|
||
|
||
**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs
|
||
under `/rust/servers/:id`. Every gameplay row carries a server id as well as a `wipe_id`, and the
|
||
module holds one sidecar client per configured server.
|
||
|
||
This is the dry run's finding 2 taken at face value: *"one module, one game" is not the same as "one
|
||
module, one server"*. Retrofitting an `:id` segment through every route, table and page is the
|
||
expensive version, and a Rust community runs several servers by default.
|
||
|
||
### R9 — the live map, with every layer toggleable
|
||
|
||
**Decided 2026-09-15 (org lead).** The site offers a live map, and **each layer is an operator switch
|
||
set to public / players-only / admin-only** through the existing visibility framework
|
||
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md)).
|
||
|
||
**The map is two problems and they take different paths, which is the thing to get right before
|
||
building either:**
|
||
|
||
- **The map image is static content on the game host** — `proceduralmap.<size>.<seed>.<save>.map`
|
||
beside the world save, regenerated only on a wipe. That is exactly [kit][kit] ch. 3 §2b's case, and
|
||
it takes that shape: **request/reply, never events** (a sidecar that broadcast it would write
|
||
megabytes into its own store and fan them at every client), **one in flight with an explicit busy**,
|
||
**two stages — what exists, then what changed**, and a **derivation version** separate from the
|
||
protocol so improving how we read the file invalidates a cached image whose source hash did not
|
||
move. And **no import on boot**: a wipe is an event the operator knows about and the website does
|
||
not.
|
||
- **Everything moving on it is live state** down the ordinary read path — monuments, cargo ship,
|
||
patrol helicopter, airdrops, locked crates, and player positions.
|
||
|
||
**The layer switches are a security boundary, not a preference.** Public player positions in Rust are
|
||
a competitive-advantage leak — anyone, including people who do not play on the server, could locate
|
||
players and infer base positions. The default posture is monuments and world events public, player
|
||
and base layers admin-only, and an operator opening one up is a deliberate act with the consequence
|
||
stated on the switch.
|
||
|
||
**This is the only asset-bridge work in scope.** Item icons and the 2,590 workshop skin ids stay out
|
||
of v1; kill feeds and kit lists render as text.
|
||
|
||
### R10 — the Android app is in this workstream, capability-driven, trailing by one phase
|
||
|
||
**Decided 2026-09-15 (org lead).** Full Rust support in the app, not a degradation check. It decides
|
||
which screens to show from **`GET /api/v1/public/modules`** — each started module's `capabilities`
|
||
array — and each app leg lands **one phase after** the website surface it consumes is merged, so it
|
||
is always built against a real endpoint rather than a planned one.
|
||
|
||
Two endpoints that are *not* this and are easy to confuse with it, both checked on 2026-09-15:
|
||
`/api/v1/public/status` returns site mode plus a version block — the first-run probe and
|
||
version-mismatch guard, not a feature manifest — and `/api/health` on the internal app is a liveness
|
||
probe returning `{status:'ok'}`. Neither can say which pages exist.
|
||
|
||
§2.9's rule governs: **treat an unknown capability as absent, and never infer a URL from one.**
|
||
|
||
### R11 — a small read-only set of Discord slash commands
|
||
|
||
**Decided 2026-09-15 (org lead).** Questions answered from data the module already holds — server
|
||
status, wipe schedule, leaderboards, who is online. No write verbs, and the account link stays on the
|
||
two surfaces R1 names rather than acquiring a third.
|
||
|
||
The trap to carry in from the Teams work: **ephemerality is fixed at the deferral**, so a command
|
||
that might refuse must defer ephemeral or its refusal goes public in the channel. And the registries
|
||
have no removal path, which is an argument for adding a command late rather than early.
|
||
|
||
### R12 — per-wipe detail plus all-time rollups
|
||
|
||
**Decided 2026-09-15 (org lead).** Every gameplay row carries `wipe_id`; leaderboards default to the
|
||
current wipe; a separate rollup accumulates per player across wipes so a returning player's history
|
||
survives the monthly reset.
|
||
|
||
The truncation is a **runtime operation on a module route, never a schema one** — §2.6's leading-verb
|
||
allowlist forbids `DELETE` and `TRUNCATE` in a fragment precisely because the fragment replays at
|
||
every boot and would empty the table on each restart.
|
||
|
||
### R13 — two extension slots: `admin.users.detail` and `site.footer.status`
|
||
|
||
**Decided 2026-09-15 (org lead).** An operator looking at a user sees their linked Steam identity,
|
||
per-server stats and site-authored permission grants in core's own admin user page; the footer
|
||
carries a shard-status indicator on every page.
|
||
|
||
One module per slot, so claiming them also reserves them. And the naming rule matters for anyone
|
||
reading this later: **a slot is named for a PLACE, never for a meaning** — `site.footer.status` is
|
||
"the status-ish spot in the footer", not core knowing what a game server is.
|
||
|
||
### R14 — `/rust` on all three tiers
|
||
|
||
**Decided 2026-09-15 (org lead).** `public`, `admin` and `player` all mount `/rust`, exactly as
|
||
`module-uo` mounts `/uo`. Sub-surfaces are path segments: `/rust/servers/:id`, `/rust/map`,
|
||
`/rust/clans`.
|
||
|
||
Chosen deliberately because **prefixes share one namespace with core's own and the loader's collision
|
||
probe cannot see all of core's** — several core endpoints are mounted at the tier root rather than
|
||
under a prefix. A noun from our own domain that equals the module id cannot collide, where
|
||
`/servers` or `/map` very well might.
|
||
|
||
### R18 — plugin configuration is editable from the site, with a generated form and an auto-reload
|
||
|
||
**Decided 2026-09-15 (org lead).** An admin edits any loaded plugin's configuration from the website
|
||
and it reloads automatically. Two tiers:
|
||
|
||
- **Base:** the site walks `oxide/config/` **recursively** and **generates a form from the values
|
||
themselves** — a boolean becomes a toggle, a number a numeric field, a string a text box, an array
|
||
a list, a nested object a group. It is derived at read time, so **it works for whatever plugins
|
||
happen to be installed**, including ones added or removed after we shipped.
|
||
- **Advanced:** raw JSON editing for anything the generated form cannot express.
|
||
|
||
This is the same posture as R2 — the site as the authority over the game host — but a different
|
||
*shape*. R2 is continuously reconciled state pushed on every connect; this is **request/reply,
|
||
on demand** ([kit][kit] ch. 3 §2a). Do not build it on the permission mirror.
|
||
|
||
**The mechanics Oxide gives us, all verified 2026-09-15:** configs live at
|
||
`oxide/config/<PluginName>.json`; `oxide.reload <name>` rereads one; and **`OnPluginLoaded` /
|
||
`OnPluginUnloaded` are real hooks** in the Server category, so *whether a reload actually succeeded
|
||
is observable* rather than assumed.
|
||
|
||
#### Discovery is a recursive walk, and it stops at `oxide/config/`
|
||
|
||
**Amended 2026-09-15 (org lead).** Configuration is **not** one flat `oxide/config/<Plugin>.json` per
|
||
plugin. Plugins nest — `oxide/config/<Mod>/whatever.json`, and deeper — and one plugin may own
|
||
several files. So discovery is a **recursive walk** of the config tree, and the UI groups by plugin
|
||
rather than assuming one file each.
|
||
|
||
Four things follow, and the first is a boundary rather than a detail.
|
||
|
||
**`oxide/data/` is not the settings surface, and must not be walked into.** `DataFileSystem` writes
|
||
to `oxide/data/`, and that is **live state, not configuration**. The base set makes the point by
|
||
itself: Kits keeps `Kits/kits_data.json` and `Kits/player_data.json`, ZoneManager keeps
|
||
`ZoneManager/zone_data.json`, Clans keeps `clan_data.json` (and a legacy `clans_data.json` beside it,
|
||
which is also a reminder that these names are not stable). Editing those from a web form edits
|
||
**players' kit cooldowns and the live zone definitions**, a running plugin overwrites the change on
|
||
its next save, and `oxide.reload` does not make most plugins safely re-read them. It is a different
|
||
problem with a different answer and it is deliberately out of scope. If a plugin's *settings* genuinely
|
||
live under `data/`, that is a per-plugin exception someone opts into knowingly, never something the
|
||
walk discovers on its own.
|
||
|
||
**The reload target cannot be inferred from the path.** `oxide/config/Foo/bar.json` may belong to
|
||
plugin `Foo`, or to something else entirely — the folder name is convention, not contract. So the
|
||
reload target is an **explicit field with the folder name as its default guess**, confirmable by the
|
||
admin. Infer it silently and the failure is the nastiest kind available here: we reload the wrong
|
||
plugin, observe `OnPluginLoaded` for *it*, and report success while the plugin that was actually
|
||
edited never re-read anything.
|
||
|
||
**A relative path from a web form is now a path-traversal surface.** Canonicalise the resolved path,
|
||
assert it is genuinely under the config root, reject absolute paths, and reject symlinks that resolve
|
||
outside. Before this amendment the feature addressed files by plugin name; now it addresses them by
|
||
path, and that is exactly the change that introduces the bug class.
|
||
|
||
**Bound the walk and the file.** A depth limit, a file-count limit and a per-file size cap — a
|
||
pathological tree must not be enumerated and a multi-megabyte JSON must not be loaded into a form.
|
||
And because one plugin can own several files, **the backup and rollback operate on the whole set a
|
||
save touches**, not one file at a time.
|
||
|
||
#### The trap that would silently corrupt every float
|
||
|
||
**JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.**
|
||
`JSON.parse('{"Rate":1.0}')` yields the number `1`, and `JSON.stringify` writes it back as `1` — so
|
||
a naive read-modify-write of a config file **silently rewrites every whole-numbered float as an
|
||
integer**, on fields nobody touched. Newtonsoft may coerce it, or may throw, and a throw at load
|
||
means the plugin does not come back.
|
||
|
||
So: **never parse the whole document, mutate, and re-serialize.** Edit the file *textually* — a
|
||
surgical replacement of the edited key's value — or use a parser that preserves number literals. The
|
||
fields at risk are exactly the ones a Rust server tunes: gather rates, multipliers, scales.
|
||
|
||
#### Five more limits of inferring a schema from values
|
||
|
||
Worth stating because the feature's whole promise is that it works without knowing the plugin:
|
||
|
||
- **Empty arrays and `null` carry no type.** Nothing can be inferred; render them advanced-only.
|
||
- **Enum-like strings are indistinguishable from free text.** There is no allowed-value set to read,
|
||
so a string field is a text box and cannot be validated.
|
||
- **There are no descriptions, no minimums and no maximums.** The key name is the entire label —
|
||
which is survivable because Oxide convention favours readable keys (BetterChat really does ship
|
||
`"Maximal Titles"` and `"Reverse Title Order"`).
|
||
- **Nested objects and arrays of objects need recursion**, a depth limit, and a fall-back to raw JSON
|
||
past it.
|
||
- **The file after a reload may not be what we wrote.** Oxide merges missing defaults and saves, so
|
||
re-read after reloading rather than assuming our write is the current state.
|
||
|
||
#### Safety, and why it needs more than the usual
|
||
|
||
**A bad config does not fail the write — it fails the next load, and the plugin stays down.** Since
|
||
R6 and R17 make four plugins *required*, a broken ZoneManager config takes event participation with
|
||
it. So the write path is:
|
||
|
||
1. Read with a **version** (hash or mtime) and require it back on write — an operator editing on
|
||
disk at the same time gets a conflict rather than a silent overwrite.
|
||
2. Validate the edited document parses.
|
||
3. **Back up the current file**, write, reload.
|
||
4. **Watch for `OnPluginLoaded` within a window.** If it does not arrive, **restore the backup and
|
||
reload again, automatically**, and report the failure with whatever Oxide logged.
|
||
|
||
That rollback is the feature's real content. Without it this is a web form that can take the shard's
|
||
plugins down one typo at a time.
|
||
|
||
**Two more obligations.**
|
||
|
||
**Redact secrets.** Plugin configs routinely hold API keys and Discord webhooks. A config reader
|
||
hands those to anyone who can open the page. Mask values whose key matches the usual shapes — key,
|
||
token, secret, password, webhook — and treat them **write-only**, exactly as the platform already
|
||
treats the uo-link token. This is a real leak vector and the generated-form approach walks straight
|
||
into it.
|
||
|
||
**Gate it on its own site permission and audit every write** — who changed which key, from what to
|
||
what, and whether the reload succeeded. It is an admin writing to the game host's filesystem, which
|
||
is the most powerful thing the site can do to a server.
|
||
|
||
#### One distinction to keep
|
||
|
||
**Editing a plugin's config file is not a lease.** A lease (§9) borrows a *convar* for a while and
|
||
the game restores it on a deadline. This writes a *file* and is permanent until someone changes it
|
||
back. They look similar from a web form and they are not the same mechanism — an event should never
|
||
reach for this one.
|
||
|
||
### R17 — ZoneManager joins the required base set, because events need to know where people are
|
||
|
||
**Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u,
|
||
3.1.14 released 2026-09-02, MIT, Rust, ~218k downloads) is a fourth **required** plugin, not an
|
||
optional one. It is what makes an event able to answer *where a player is*.
|
||
|
||
Reading its source changed two things this plan had been vague about, and promoted one action out of
|
||
the optional tier.
|
||
|
||
**Its API is private methods reached through Oxide's reflection `Call()`** — no `[HookMethod]`, no
|
||
`API_` prefix, which is a third convention among the four base plugins and worth knowing before
|
||
someone goes looking for one that is not there.
|
||
|
||
| Surface | What it gives an event |
|
||
|---|---|
|
||
| `IsPlayerInZone(zoneId, player)` → `bool` | the direct question |
|
||
| `GetPlayerZoneIDs(player)` → `string[]` | every zone a player is in |
|
||
| **`GetPlayerZoneIDsNoAlloc(player, List<string>)`** | the allocation-free variant — **the one to use on any sweep** |
|
||
| `CreateOrUpdateZone(zoneId, args, position)` | make a zone |
|
||
| **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* |
|
||
| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one; owner-scoped **only against another plugin's zone**, not against an unowned one (§12.4) |
|
||
| `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source |
|
||
|
||
And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`,
|
||
`OnZoneInitialize` / `OnZoneUpdated` / `OnZoneDestroyed` / `OnZoneErased`, plus `CanSpawnInZone`,
|
||
which is a veto our bridge abstains from like every other.
|
||
|
||
**Three things this settles.**
|
||
|
||
**Participation stops being the hard part.** [`EVENTS.md`](../../website/EVENTS.md) §H rates
|
||
participation *"the hard part"* for UO and *"substantially easier"* for Rust because hooks carry
|
||
attacker and victim. ZoneManager makes it exact rather than merely easier: `OnEnterZone` and
|
||
`OnExitZone` are **presence transitions delivered as events**, so the participation ledger is fed
|
||
from what happened rather than reconstructed from a sweep. An action's reply carries its
|
||
`participants` envelope member, and this is where that member gets its content.
|
||
|
||
**Advance conditions become expressible.** A phase that waits until *"ten players are at the
|
||
monument"* is a real gate rather than a wish — it reads zone membership. Without ZoneManager the
|
||
same condition is distance arithmetic against a point, recomputed on a timer, which is both more
|
||
expensive and less accurate.
|
||
|
||
**`rust.zone.open` moves from the optional tier into the base catalogue, and it can be
|
||
`reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a
|
||
**`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a
|
||
first-class notion of a zone belonging to the plugin that made it. And erasing a zone that is gone is
|
||
a success, which is what `revert` needs.
|
||
|
||
> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is
|
||
> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all
|
||
> for a zone owned by **nobody**.
|
||
>
|
||
> ```csharp
|
||
> // Only compare zone owner if the owner param is provided so users can remove temporary zones
|
||
> // without needing to unload the plugin that created them
|
||
> if (owner && zoneOwner && owner != zoneOwner)
|
||
> return false;
|
||
> ```
|
||
>
|
||
> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So
|
||
> `EraseTemporaryZone(us, "<operator's zone>")` **deletes it and returns `true`**, indistinguishable
|
||
> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was
|
||
> erased by an `EraseTemporaryZone` call from an unrelated plugin.
|
||
>
|
||
> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We
|
||
> still keep our own map from core's resource reference to the zone id, and that map is now
|
||
> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not
|
||
> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not
|
||
> evidence the zone was ours.
|
||
|
||
**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and
|
||
`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of
|
||
them. The emit path already enqueues and returns, so the game cannot stall — but **the bridge should
|
||
subscribe selectively rather than forwarding every transition in every zone**. A zone no event cares
|
||
about should cost nothing on the wire. Decide the filter with the hooks in front of you at phase 12,
|
||
and measure it, because a sweep over `GetPlayerZoneIDsNoAlloc` is cheap and a flood of wire traffic
|
||
is not.
|
||
|
||
### R15 — an optional-integration tier, with BetterChat as the first member
|
||
|
||
**Decided 2026-09-15 (org lead).** Beyond the required base set (R6) the module carries a tier of
|
||
**optional** integrations: each detects its plugin through `[PluginReference]`, degrades cleanly to
|
||
absent, and adds something the site already knows how to compute.
|
||
|
||
The first named member is **[BetterChat](https://umod.org/plugins/better-chat)** (LaserHydra, 5.2.15,
|
||
MIT, Universal/Covalence, ~200k downloads) — *"manage chat groups, customize colors, and add
|
||
titles"*. The motivating case is **titles earned from leaderboards**: top of the wipe's kill board
|
||
gets a tag in chat.
|
||
|
||
**Its integration point is a pull, not a push, and that is why it is a good first member.**
|
||
`API_RegisterThirdPartyTitle(Plugin plugin, Func<IPlayer, string> titleGetter)` registers a callback,
|
||
and BetterChat invokes it per player when it renders a chat line. So a leaderboard title is a *pure
|
||
function of state we already hold* — nothing is written into BetterChat, nothing can go stale, and
|
||
there is no drift to reconcile. Contrast R2, which is a push and needs a whole reconcile story.
|
||
|
||
**The one trap, and it is chapter 4's rule applied to somebody else's callback:** that getter runs
|
||
**synchronously on the chat path**. It must be a cheap in-memory lookup — never a socket call, never
|
||
a database query, never anything that can block. A title that costs a round trip is a chat message
|
||
that costs a round trip.
|
||
|
||
Its other two API methods, `API_AddGroup(group)` and `API_SetGroupField(group, field, value)`, pair
|
||
naturally with R2: the site already authors permission groups, so a site-authored group can carry a
|
||
chat colour and tag. That is a push and would need the same drift posture R2 has; it is a phase-17
|
||
decision, not a given.
|
||
|
||
**The tier is open-ended by design.** Other integrations get added as they prove useful, and the bar
|
||
for each is the one this plan applies everywhere: it must fulfil the contract — declare honestly,
|
||
degrade to absent, and never make the module's own surfaces depend on something that may not be
|
||
installed.
|
||
|
||
### R16 — the reward action grants the RIGHT to redeem, not the items
|
||
|
||
**Decided 2026-09-15 (org lead).** An event reward does **not** call `GiveKit`. It grants the
|
||
**permission that gates a kit**, and the player redeems it themselves in game, whenever they next
|
||
log in.
|
||
|
||
**Kits already has exactly this model built in**, which is what makes it cheap: every kit carries a
|
||
`RequiredPermission`, `GiveKit`'s own path checks it before handing anything over, and the in-game
|
||
kit menu renders a kit the player lacks the permission for as locked rather than hiding it.
|
||
`GetKitInfo` returns that permission under `["permission"]`, so the module can read which kits are
|
||
gated and which are open to everyone.
|
||
|
||
This is a better design than the one it replaces, and it is worth being explicit about how much it
|
||
removes:
|
||
|
||
- **The offline-grant problem disappears entirely.** `GiveKit` needed a connected `BasePlayer`, so an
|
||
event firing at two in the morning rewarded only whoever happened to be online. An entitlement
|
||
waits. **This closes the open question §3 carried** — no pending-grant queue, no second
|
||
at-most-once store, none of it.
|
||
- **It is the same machinery as R2, not a second mechanism.** A reward becomes a permission grant
|
||
authored by the site and mirrored into Oxide — the thing phase 7 already builds. One permission
|
||
authority, one drift story, one audit trail.
|
||
- **`reversible: 'ledger'` becomes honest**, where a direct grant could only ever be `'none'`. See
|
||
*"What phase 13 must declare honestly"* below.
|
||
- **The player gets agency.** They redeem when they want it, where they want it, with Kits' own
|
||
cooldown and use limits still applying — rather than having items appear in their inventory,
|
||
possibly while they are somewhere it is a liability.
|
||
|
||
**One design note the option source has to carry.** A kit with an **empty** `RequiredPermission` is
|
||
open to everybody, so granting a permission for it rewards nobody with anything. The authoring form's
|
||
kit dropdown must surface which kits are permission-gated and refuse — or at minimum warn loudly —
|
||
on one that is not. That is a real refusal with a real reason, and exactly what R3's envelope is for.
|
||
|
||
## 3. Open questions
|
||
|
||
**None.** Both questions this section carried were closed on 2026-09-15.
|
||
|
||
*Clans in the base set while the Team provider reads first-party* was confirmed as the intended
|
||
reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
|
||
provider is fed from the first-party system that actually publishes membership transitions (R5).
|
||
|
||
*Offline reward grants* was dissolved rather than answered. **R16 changed the noun**: the reward
|
||
action grants an entitlement instead of items, and an entitlement does not need the player to be
|
||
online. The persisted pending-grant queue that question was weighing is not needed at all.
|
||
|
||
## 4. The test rig
|
||
|
||
`D:\rust` on the org lead's workstation. **Brought current in phase 0 (see §12):** build
|
||
**25230300**, Oxide **2.0.7716**, a fresh procedural world (seed 1234, size 4000) generated for this
|
||
wipe, and all four base plugins loaded. Its Oxide permission store holds a `default` and an `admin`
|
||
group with one admin user, so R2's mechanism was exercised on day one and works.
|
||
|
||
A wipe keeps `server/server1/cfg/`. That directory holds `users.cfg`, and `users.cfg` holds the
|
||
`ownerid` line — delete the whole identity directory and you silently remove the operator's own
|
||
ownership along with the map.
|
||
|
||
Three traps recorded here because each cost time before it was understood:
|
||
|
||
- **`start.bat` never updated anything — the 2026-09-15 diagnosis was wrong, corrected in phase 0.**
|
||
The script was read as *"updates `C:\rustserver`, runs `D:\rust`"*, and the fix changed the path to
|
||
`d:\rust\`. The path was never the problem. **steamcmd requires `+force_install_dir` before
|
||
`+login`**, and the script had it after:
|
||
|
||
```
|
||
steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit
|
||
→ Please use force_install_dir before logon!
|
||
→ Error! App '258550' state is 0x486 after update job.
|
||
```
|
||
|
||
So the flag was discarded, the update ran against steamcmd's own directory, and the job errored out
|
||
every single time. It updated **no** directory, ever — which is the actual reason `D:\rust` fell a
|
||
wipe behind, and why `C:\rustserver` sits at the *same* stale buildid rather than a newer one.
|
||
Now reads `+force_install_dir d:\rust\ +login anonymous +app_update 258550 +quit`; the pristine
|
||
original is kept at `start.bat.bak` and the path-only fix at `start.bat.broken-order-20260915`.
|
||
With the ordering right, the run is a delta and takes minutes.
|
||
|
||
- **Re-extract Oxide after every `app_update`.** Updating the server and re-installing Oxide together
|
||
is the standard operator routine, not a discovery — Oxide ships a *patched* `Assembly-CSharp.dll`
|
||
and a Steam update restores Facepunch's. Recorded here only for the mechanical detail: the update
|
||
does **not** remove `Oxide.Core.dll` and friends, so a half-done install still *looks* Oxided while
|
||
loading no plugins and raising no hook. Check the size rather than the directory — on build
|
||
25230300 vanilla is 9,758,544 bytes and Oxide 2.0.7716's is 9,953,280.
|
||
- **`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,953,280, 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
|
||
|
||
**Twenty phases, roughly doubled from the first draft** — the contract audit in §7 is why, and the
|
||
honest reading is that the first list was a game-bridge plan with a website module bolted on, where
|
||
the kit treats the module as the bulk of the work.
|
||
|
||
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7b are
|
||
the permissions and remote-administration product. 9 is Teams. 10 is the notifications set, whose catalogue is **§10**. 12–13
|
||
are events, whose catalogue is **§9**. 14 is the map. 18 is how any of it reaches somebody who is
|
||
not us. The Android legs (5, 8, 11, 15) each trail the
|
||
website surface they consume by one phase, per R10.
|
||
|
||
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. One acceptance criterion is **open**: zone occupancy needs a connected player and cannot be closed headlessly (§12.5) | 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 |
|
||
| 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 |
|
||
| 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 |
|
||
| 7 | **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 |
|
||
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `oxide/config/` (never `oxide/data/`), generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
|
||
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
||
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
||
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
|
||
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
||
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
|
||
| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it |
|
||
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
|
||
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
|
||
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
|
||
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
|
||
| 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied |
|
||
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is 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 (12 before 13). [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 13 must declare honestly
|
||
|
||
> **Rewritten 2026-09-15 by R16.** This section previously argued that a kit reward could only be
|
||
> `reversible: 'none'`, because there is no honest way to un-grant loot a player has already spent.
|
||
> That was correct *about a direct grant* and R16 stopped doing direct grants. The reasoning is kept
|
||
> below in its corrected form because the shape of the mistake is the reusable part: **the action was
|
||
> declared around the wrong noun.** What the event makes is not loot; it is an entitlement.
|
||
|
||
**The reward action grants an entitlement, and an entitlement is reversible.** `revert` revokes the
|
||
permission, removing one that is not there is a success, and it is idempotent by construction — so
|
||
**`reversible: 'ledger'` is the honest declaration**, and core's ledger sweep does real work on every
|
||
terminal path.
|
||
|
||
One consequence to state rather than discover: **a player who redeemed before the revert keeps the
|
||
items.** That is correct and not a hole. The ledgered resource is the *grant*, and reverting it
|
||
withdraws the entitlement rather than the consumption — the same way cancelling a coupon does not
|
||
un-eat the meal. An operator reading the run console should see that distinction in the wording.
|
||
|
||
**`cost()` counts permission grants, and unlike a kit count it is exactly knowable before dispatch.**
|
||
That removes the whole class of problem chapter 5 §4 warns about: there is no "declare the maximum
|
||
because you cannot know until the answer comes back". One recipient is one grant. Core prices `cost`
|
||
before dispatch and never reconciles it, so being able to count precisely is worth more than it
|
||
sounds.
|
||
|
||
**And the idempotency key largely stops mattering here**, which is the tidiest part of R16. Chapter 5
|
||
§2 draws the line itself: *"A key is for a write whose repetition would be a second EFFECT —
|
||
creating, granting, announcing. A write that SETS a value to X is idempotent by its own nature."* A
|
||
permission grant is a set. Pass the key through anyway — it costs nothing and it is what the
|
||
contract expects — but the failure mode it exists to prevent, a socket hiccup producing a second set
|
||
of everything, no longer has a way to happen.
|
||
|
||
The monthly wipe is still the case `revert`'s tolerance rule was written for: if a wipe or a rebuilt
|
||
host clears Oxide's permission store, every ledgered grant is invalidated at once and **"gone, and
|
||
that is fine" is a success**. Note this is also where R2 pays for itself twice — the site re-pushes
|
||
its whole permission set on the next connect, so an entitlement an event granted comes *back* rather
|
||
than being quietly lost.
|
||
|
||
## 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.
|
||
|
||
## 7. Contract coverage audit
|
||
|
||
Added 2026-09-15 after re-reading the whole kit rather than only chapters 3-5. **The first draft of
|
||
§5 was built from the game-facing chapters and under-planned the website module by a wide margin** —
|
||
the template makes eight of the ten non-event registrations and the plan covered three. Every element
|
||
is listed with the phase it now lands in; the column worth reading is *"was it in the first draft"*,
|
||
because that is the shape of the mistake.
|
||
|
||
### The server handshake — ten registrations plus two hooks (ch. 2)
|
||
|
||
| Call | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `registerRoutes` | yes | 4 |
|
||
| `registerTeamProvider` | yes | 9 |
|
||
| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 12-13 |
|
||
| `onBoot` / `onShutdown` | implicit only | 1 |
|
||
| `registerExtension` | **no** | 4 (`site.footer.status`), 6 (`admin.users.detail`) |
|
||
| `registerNotificationStreams` | **no** | 10 |
|
||
| `registerEventTriggers` | **no** | 10 |
|
||
| `registerAudiences` | **no** | 10 |
|
||
| `registerEngagementSeeds` | **no** | 10 |
|
||
| `registerAnnounceLeg` | **no** | 10 |
|
||
| `registerPostHook` | **no** | 10 |
|
||
| `registerSlashCommands` | **no** | 16 |
|
||
|
||
Triggers, audiences and seeds are one phase because they are a **matched set** in
|
||
`template/server/index.js`, not three independent gaps — see R7.
|
||
|
||
### The bundle's own parts
|
||
|
||
| Part | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 4 |
|
||
| `mounts` and prefix choice | **no** | 1 (R14) |
|
||
| `schema.sql` | yes | 1, 3 |
|
||
| `purge.sql` | **no** | 1 |
|
||
| `swagger-fragment.json` + generator + `check:swagger` | **no** | 1 |
|
||
| `vite.config.js` aliases / shims / `checkExternals` | **no** | 1 |
|
||
| `checkImports.js` | **no** | 1 |
|
||
| `release.yml`, install manifest, `sha256`, host allowlist | **no** | **2** — there was no packaging phase at all |
|
||
|
||
Phase 1 carries most of these on purpose. [kit][kit] ch. 1's whole argument is to get every seam
|
||
working at once with almost nothing in them, so that afterwards you break exactly one at a time.
|
||
|
||
### The client half
|
||
|
||
| Part | In the first draft | Phase |
|
||
|---|---|---|
|
||
| `registry.registerRoutes` / `registerNav` | yes | 4 |
|
||
| `declareModuleSlot` | **no** | 9 — the kit: *"you will need it the moment your game has anything like a guild"* |
|
||
| `registerFeatureProvider` | **no** | 4 |
|
||
| UI kit discipline (`PublicLayout` `shell`, `PageHeader` props) | **no** | 4 |
|
||
|
||
### Beyond the module
|
||
|
||
| Item | In the first draft | Phase |
|
||
|---|---|---|
|
||
| Sidecar rpc correlation | implicit | 1, stated |
|
||
| Asset bridge (ch. 3 §2b) | **no** | 14 — **map image only** (R9) |
|
||
| `.profile` landing page | **no** | 19 |
|
||
| `runicgateway.com` | **no** | 19 |
|
||
| Android app | **no** | 5, 8, 11, 15 (R10) |
|
||
| `docs/` | yes | every phase, plus 19 |
|
||
|
||
**What the audit cost the schedule: twelve phases.** That is the honest number, and it is worth
|
||
recording because the under-planning had one cause — reading the chapters that describe the game
|
||
bridge and treating the module as the thin part, when the kit says in its first paragraph that the
|
||
website module is most of the work.
|
||
|
||
## 8. Questions the audit raised — all answered
|
||
|
||
Every question §7 produced was put to the org lead on 2026-09-15 and answered the same day. They are
|
||
recorded as R7–R14 in §2 rather than repeated here:
|
||
|
||
| Question | Answer | Decision |
|
||
|---|---|---|
|
||
| Notifications and engagement in v1? | the full matched set | R7 |
|
||
| How many servers does the UI support? | multi-server from the start | R8 |
|
||
| The asset bridge? | **only** the live map — not item icons, not skins | R9 |
|
||
| Android in this workstream? | full app, capability-driven, trailing by one phase | R10 |
|
||
| Discord slash commands? | a small read-only set | R11 |
|
||
| What survives a wipe? | per-wipe detail plus all-time rollups | R12 |
|
||
| Extension slots? | `admin.users.detail` **and** `site.footer.status` | R13 |
|
||
| Which mount prefixes? | `/rust` on all three tiers | R14 |
|
||
|
||
§3 is empty; both were closed on the same day.
|
||
|
||
## 9. The event catalogue
|
||
|
||
Added 2026-09-15. **Phases 12–13 described the event *mechanism* and never the *catalogue*** — one
|
||
budget and one lease as a proof of life, which is a skeleton rather than a product. This section is
|
||
what the module actually declares.
|
||
|
||
[`EVENTS.md`](../../website/EVENTS.md) **§H is a Rust/Oxide compatibility section that already
|
||
sketched this**, and it should have been read before §5 was written. What follows takes its ids and
|
||
its reasoning as the starting point rather than inventing a parallel set.
|
||
|
||
> **§H's thesis, and it is the one to design around:** *"The lease is the primitive that travels, not
|
||
> the spawn. Double gather rate for the weekend is the canonical Rust community event, and it is
|
||
> exactly lease-with-expiry. Spawning creatures at a landmark is UO-shaped; holding a value for four
|
||
> hours is every game."* It also rates Rust the **easier** case than UO, because Oxide's convars are
|
||
> live by default where ServUO's are mostly cached at boot.
|
||
|
||
### Budgets — what core counts and bounds
|
||
|
||
| Dimension | Counts |
|
||
|---|---|
|
||
| `rust.prefabs` | objects placed into the world by a run |
|
||
| `rust.zone.minutes` | zone time held — real since R17 |
|
||
| `rust.grants` | entitlements granted (R16) |
|
||
| `rust.announcements` | in-game broadcasts |
|
||
|
||
**Caps are per run, and R8 makes that load-bearing.** §H: `run.scope` is part of a run's unique key,
|
||
so one definition fanning out to six servers is **six separate budgets, not one shared pool**. An
|
||
operator setting a cap of 30 prefabs is setting it per server. Say so on the field.
|
||
|
||
### Option sources — what fills a dropdown
|
||
|
||
| Source | Filled from |
|
||
|---|---|
|
||
| `rust.options.kits` | Kits `GetKitNames` / `GetAllKits`, **flagged by whether `RequiredPermission` is set** (R16) |
|
||
| `rust.options.groups` | Oxide permission groups (§H names this one) |
|
||
| `rust.options.permissions` | registered permissions |
|
||
| `rust.options.prefabs` | a plugin-declared constructible allowlist — the analogue of UO's spawn atlas |
|
||
| `rust.options.monuments` | monument names, shared with the map work (R9) |
|
||
| `rust.options.zones` | ZoneManager `GetZoneIDs` / `GetZoneName` (R17) |
|
||
|
||
Every one resolves from live data and returns `[]` on failure rather than defending with a hardcoded
|
||
list that will be wrong. A source that refuses degrades its field to free text with a warning and
|
||
never blocks the form.
|
||
|
||
### Leases — values borrowed with a deadline
|
||
|
||
The heart of it, and the thing to build first.
|
||
|
||
| Lease | Value |
|
||
|---|---|
|
||
| `rust.rate.gather` | gather rate multiplier |
|
||
| `rust.rate.craft` | craft speed |
|
||
| `rust.rate.smelt` | smelting speed |
|
||
| `rust.rate.decay` | decay scale |
|
||
| `rust.time.night` | night length |
|
||
| `rust.population.<kind>` | spawn population multipliers |
|
||
| `rust.group.membership` | **a time-limited permission group — weekend VIP** |
|
||
|
||
**`rust.group.membership` is the one §H names that R16 did not, and the pair is the whole design.**
|
||
R16 settled that a *permanent* earned entitlement is an **action** with `reversible: 'ledger'` — grant
|
||
the kit's permission, revert revokes it. §H settles that a *time-limited* group is genuinely
|
||
**`core.lease`** — held with a deadline the game enforces on its own, restored when it expires
|
||
without core having to come back. Same underlying permission mirror (R2), two different shapes,
|
||
and choosing the wrong one is the mistake: a weekend VIP implemented as a grant is a VIP who stays
|
||
one for ever if the website goes away.
|
||
|
||
**Every key gets verified live before it is advertised** — apply, observe in the running game,
|
||
restore, per key. §H's claim that Rust convars are live by default is an argument for *expecting*
|
||
them to work, never a substitute for checking. A value the server reads once at boot applies
|
||
cleanly, reads back cleanly, and does nothing at all, and neither core nor review can catch it.
|
||
|
||
### Actions — verbs a run performs
|
||
|
||
| Action | `risk` | `reversible` | Notes |
|
||
|---|---|---|---|
|
||
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
|
||
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
|
||
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
|
||
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) |
|
||
|
||
**Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because
|
||
it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —
|
||
which is exactly why R16 could change what it grants without touching anything of core's.
|
||
|
||
## 10. The engagement catalogue — what Rust can expose
|
||
|
||
Added 2026-09-15, answering "check the default alerts Rust can expose". R7 settled that the set
|
||
ships; this is what goes in it.
|
||
|
||
**The ceiling lattice is containment, not size** — `self`, `owner`, `subscribers`, `staff`,
|
||
`members`, `authenticated`, `everyone`, and the flat reading is the trap. `staff` is **not** a
|
||
superset of `owner`: for a cheat-detection event, "one person" is *the player it was detected on*.
|
||
Every ceiling below is chosen against that, not against a ladder.
|
||
|
||
| Trigger | Source | `ceiling` | `subjectKey` |
|
||
|---|---|---|---|
|
||
| `rust.wipe.started` | `OnNewSave` | `everyone` | server |
|
||
| `rust.server.online` / `.offline` | link state transition | `everyone` | server |
|
||
| `rust.leaderboard.topped` | our own rollup (R12) | `everyone` | server |
|
||
| `rust.base.destroyed` | `OnEntityDeath` on owned building blocks | **`owner`** | player |
|
||
| `rust.kit.entitled` | R16's own grant | **`self`** | user |
|
||
| `rust.player.linked` | R1's link flow | **`self`** | user |
|
||
| `rust.clan.member.added` / `.left` / `.kicked` | first-party clan hooks (R5) | `members` | clan |
|
||
| `rust.clan.disbanded` | `OnClanDisbanded` | `members` | clan |
|
||
| `rust.player.reported` | `OnPlayerReported` | **`staff`** | player |
|
||
| `rust.login.denied` | `CanUserLogin` | **`staff`** | player |
|
||
| `rust.player.banned` / `.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **`staff`** | player |
|
||
|
||
**`rust.base.destroyed` is the one that matters most and the one most likely to be got wrong.** The
|
||
offline raid alert is the single most-wanted notification in Rust, and its ceiling is `owner` — the
|
||
player whose base it was. Ceilinged `staff` it would be useless to the person who needs it, and
|
||
ceilinged `everyone` it would broadcast base locations to the server. This is exactly the case the
|
||
lattice exists for.
|
||
|
||
**Three hooks carry data that must never widen.** `CanUserLogin` and `OnUserApproved` carry **IP
|
||
addresses**; `OnPlayerReported` carries player reports. [`README.md`](README.md) §5 already flags
|
||
these as admin-channel-only on the live feed, and the same judgement binds their triggers.
|
||
|
||
### Audiences
|
||
|
||
| Audience | Resolves to | `ceiling` |
|
||
|---|---|---|
|
||
| `rust.clan.members` | a clan's linked members | `members` |
|
||
| `rust.server.players` | linked accounts seen on a server this wipe | `authenticated` |
|
||
| `rust.wipe.participants` | everyone who played the current wipe | `authenticated` |
|
||
|
||
A resolver returns **user ids and nothing else** — never a template, a channel or an address — and
|
||
one that fails resolves to **nobody**, never to everybody and never to its last good answer. Its
|
||
params are constant, filled in when an operator saves the rule, so "the clan this event was about"
|
||
is not expressible; an event that needs that carries its own recipients.
|
||
|
||
### Seeds, and one thing to decide when building them
|
||
|
||
Bodies re-ensure every boot under a seed version; **rule groups are offered once per group key**, so
|
||
a rule appended to an existing group reaches fresh installs only. Wipe announcements, raid alerts and
|
||
clan transitions each take their own group key for that reason.
|
||
|
||
**One design note, flagged rather than decided.** `PopupNotifications` gives the module an *in-game*
|
||
alert surface, which is not one of core's channels — core resolves ids to email, in-app and push. So
|
||
an in-game popup is the module publishing to its own surface off its own trigger, not a fourth
|
||
channel core learns about. Worth settling deliberately at phase 10: a raid alert that reaches a
|
||
player's phone *and* pops on their screen next login is two mechanisms, and only one of them is
|
||
core's.
|
||
|
||
## 11. Second contract pass — `MODULE_API.md` read member by member
|
||
|
||
Added 2026-09-15, after docs#249 merged. §7 audited the plan against the **Integration Kit** and the
|
||
**template**; this pass reads [`MODULE_API.md`](../../website/MODULE_API.md) itself, enumerating every
|
||
member rather than grepping for registration names. It found one regression, one mispriced decision,
|
||
one missing declaration and a set of `ctx` members the plan had never mentioned.
|
||
|
||
### 11.1 The regression: `reconcile` was dropped
|
||
|
||
**`ctx.events.reconcile()` and an action's `reconcile()` appear nowhere in this document.** The
|
||
twelve-phase first draft had them — *"phase 6: reconcile, and the boot-id watch"* — and the rewrite to
|
||
twenty phases lost them. That is a regression in the plan, not a decision.
|
||
|
||
It matters **more** for Rust than for the game the contract was written against. [kit][kit] ch. 5
|
||
rates `reconcile` the one omission that is *"merely a lower standard rather than a broken promise"* —
|
||
but that judgement assumes a world that persists. **Rust wipes monthly, and a wipe invalidates every
|
||
ledgered resource for that server at once.** Core cannot tell a wedged sidecar from a game that
|
||
rebooted and lost everything an event made: it sees `{ ok: false, retry: true }` either way. It asks
|
||
once, at its own boot, and otherwise **waits to be told**.
|
||
|
||
`ctx.events.reconcile()` is being told, and the thing that triggers it is a **watch on the game's boot
|
||
id changing** — which is also the only way to tell a game restart from a sidecar reconnect. They are
|
||
not the same event and the second loses nothing. Two rules ride with it: anything that is not an
|
||
explicit `{ ok: true, inForce: [...] }` **leaves the ledger alone** — "I do not know" is never read as
|
||
"it is gone" — and a resource reported missing becomes `orphaned`, not `reverted`, because nobody
|
||
asked for it to go.
|
||
|
||
**Restored to phase 13**, after the actions exist, with the boot-id watch as its trigger.
|
||
|
||
### 11.2 R9 was mispriced: the visibility framework is `module-uo`'s, not core's
|
||
|
||
R9 says the map's per-layer switches work *"through the existing visibility framework
|
||
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md))"*, which reads as reuse. **It is not
|
||
reuse.** §6.3 records that `shardVisibility` is **module-owned**, and the tree confirms it — the util,
|
||
both models, the admin controller and its tests all live under `module-uo/server/`, and there is
|
||
**nothing** by that name left in `website/server`.
|
||
|
||
§2.7 forbids a module requiring anything outside its own directory, so `module-rust` cannot import a
|
||
line of it. **It builds its own**, informed by UO's design and its document but sharing no code.
|
||
|
||
That is a real cost R9 did not price. It is not a reason to change the decision — per-layer switches
|
||
are still right, and `SHARD_VISIBILITY.md` is still the design to learn from — but phase 14 carries
|
||
a visibility layer of its own rather than a configuration of somebody else's.
|
||
|
||
### 11.3 `extensions` is a declared field, not just a call
|
||
|
||
R13 claims two slots and never says where they are declared. **`module.json` has an `extensions`
|
||
array** (§2.1, optional), and the dry run's own manifest carried `"extensions": ["admin.users.detail"]`.
|
||
Like `mounts`, it is a statement of surface that the loader holds against reality — so
|
||
`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.
|
||
|
||
### 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:
|
||
|
||
| Member | Where it lands | Why it matters |
|
||
|---|---|---|
|
||
| **`ctx.secretBox`** | 1 | Each configured server's sidecar token is a secret at rest. Core encrypts its own (AES-256-GCM, write-only in the API, never returned to any client) and hands a module the same facility — so R8's several tokens get the platform's existing posture rather than a new one |
|
||
| **`ctx.middleware.rateLimit`** | 6 | R1 requires the link code be rate-limited. This is the mechanism; `accountChangeLimiter` sits beside it for the account-facing half |
|
||
| **`ctx.uploads`** | 14 | Where R9's map image actually lands. The plan described fetching it over the bridge and never said where it goes |
|
||
| **`ctx.activity.log`** | 7, 7b | R2's permission changes and R18's config writes both owe an audit trail. Core has an activity log; neither needed inventing one |
|
||
| **`ctx.teams.publish`**, **`ctx.teams.activity.push`**, **`ctx.teams.reconcile`** | 9 | Teams is more than the provider. The plan named only `registerTeamProvider`, which answers core's questions — these are how a module *pushes* a change and asks for reconciliation |
|
||
| **`ctx.posts`** | 10 | The CMS surface behind `registerAnnounceLeg` and `registerPostHook` |
|
||
| `ctx.events.emit`, `ctx.inbox.push`, `ctx.push.publish` | 10 | The three send paths §10's catalogue implies and never named |
|
||
| `ctx.users.getById`, `ctx.settings.*`, `ctx.validator`, `ctx.db.query`, `ctx.paths.moduleRoot`, `ctx.log`, `ctx.express`, `ctx.auth.getUserFromRequest`, `ctx.site.baseUrl`, `ctx.moduleId` | throughout | Ordinary plumbing; listed so the narrowing is visible |
|
||
|
||
`ctx` is **a curated list, not core's internals** — `ctx.auth` is one function rather than core's whole
|
||
auth facade, because minting a session is core's job and a module needs to *read* one. Expect to want
|
||
something that is not there; that is a minor-version conversation, never a reason to reach around it.
|
||
|
||
### 11.5 §6.8 — a trigger, a rule and an audience outlive the module that declared them
|
||
|
||
A constraint on phase 10 and on purge that the plan did not carry.
|
||
|
||
`engagement_rules.trigger_id` is a plain `VARCHAR` — **no foreign key, no cascade** — deliberately, so
|
||
a module can be removed and reinstalled without destroying an operator's rules. The consequence:
|
||
|
||
- **A rule whose trigger is unregistered shows `dormant`** — never an error, never auto-deleted.
|
||
- **The same for an unregistered audience**: it resolves to the empty set and shows dormant, which is
|
||
**not the same answer as "resolved to nobody"** and must not be rendered as if it were.
|
||
|
||
The failure that prevents is exact: **an id that stops resolving must never silently become a send to
|
||
a different set of people.**
|
||
|
||
### 11.6 The two client lists, in full
|
||
|
||
Recorded because §7 said "UI kit discipline" without saying what is in it. Both are **closed and
|
||
curated** — adding a member is a minor version bump, changing an existing prop is a major one.
|
||
|
||
**`registry`** — `registerRoutes`, `registerNav`, `registerExtension`, `registerFeatureProvider`,
|
||
`declareModuleSlot` (1.6.0), plus the read side, `routesFor` and `featureProviders`.
|
||
|
||
**`ui`** — `PublicLayout`, `PageHeader`, `Loading`, `ErrorState`, `EmptyState`, `useAsync`, `useAuth`,
|
||
`useSite`, `Slot` (1.6.0). Anything else — tables, chips, tabs, editors — **your chunk carries it**.
|
||
|
||
### 11.7 One confirmation for R10
|
||
|
||
§2.9: `GET /api/v1/public/modules` returns **only `started` modules**, with four fields and no state,
|
||
no failure stage and no failure reason. A `disabled` or `startup_failed` module is simply **absent**.
|
||
|
||
So R10's capability probe already has the behaviour the app wants: a Rust module that failed to boot
|
||
makes the app render a site *without* those screens, rather than one advertising screens that `503`.
|
||
The app needs no failure handling for this case because core does not expose the failure.
|
||
|
||
## 12. Phase 0 as built — the rig, 2026-09-15
|
||
|
||
The rig is current and the base set runs. Five things were learned that the plan had either wrong or
|
||
had never asked, and three of them change work in later phases.
|
||
|
||
### 12.0 What the rig is now
|
||
|
||
| | Before | After |
|
||
|---|---|---|
|
||
| Server build | `24613624` (2026-08-13) | **`25230300`** (2026-09-10) |
|
||
| Oxide | `2.0.7585` | **`2.0.7716`** (`OxideMod/Oxide.Rust`, 2026-09-11) |
|
||
| World | seed 1234 save v287, previous wipe | regenerated for this wipe; `cfg/` preserved |
|
||
| `oxide/plugins/` | empty | Kits 4.4.9 · Clans 0.2.10 · Popup Notifications 0.2.1 · Zone Manager 3.1.14 |
|
||
|
||
All four compiled and loaded first time on the new build, at exactly the versions R6 and R17 name —
|
||
pulled fresh from `https://umod.org/plugins/<Name>.cs`, which still serves those versions and needs
|
||
no Cloudflare workaround. Server protocol `2633.288.1`.
|
||
|
||
The Oxide permission store survived the update untouched (`oxide/` is not a Steam depot directory),
|
||
so `76561198038695917` is still in `default` and `admin`.
|
||
|
||
Two instruments were built and are kept in the phase-0 scratchpad rather than committed: a
|
||
dependency-free **WebSocket RCON driver** (Node's global `WebSocket`, no `ws` package), and
|
||
**`RGProbe.cs`**, a throwaway Oxide plugin that exposes Oxide's permission API and ZoneManager's
|
||
by-name API as console commands. The probe is what made §12.2 and §12.4 observable; phase 1's plugin
|
||
skeleton can start from it.
|
||
|
||
> **One thing the RCON driver had to learn.** Oxide tags its own `Puts()` output and its warnings
|
||
> with the **identifier of the command being run**, so a first-match-wins client reads a plugin's log
|
||
> line as if it were the reply and discards the real one. It cost two wrong readings before it was
|
||
> spotted. Collect every frame in a window; do not correlate one reply per identifier.
|
||
|
||
### 12.1 The rig's own script was broken in a way the earlier diagnosis missed
|
||
|
||
Recorded in §4. In short: `start.bat` put `+force_install_dir` **after** `+login`, steamcmd discarded
|
||
it, and every update run in the rig's history errored out without updating anything. The 2026-09-15
|
||
"fix" changed the path and left the order, so it fixed nothing.
|
||
|
||
The Oxide re-install in §4 is **not** a finding — pairing a server update with an Oxide re-install is
|
||
the routine every Rust host already follows, and saying otherwise would be this plan talking down to
|
||
its own audience. One narrow consequence is still worth carrying to **phase 18**: because
|
||
`app_update` leaves `Oxide.Core.dll` and the rest in place, a `doctor` check that tests for `oxide/`
|
||
or for Oxide's assemblies **passes on a server that is mid-routine**. Compare the
|
||
`Assembly-CSharp.dll` against the Oxide build instead, so `doctor` reports the real state rather than
|
||
a directory listing.
|
||
|
||
### 12.2 Four rules the R2 permission push must obey
|
||
|
||
Verified live against the real store, granting and revoking through both the console command and the
|
||
API:
|
||
|
||
1. **`permission.GrantUserPermission` silently no-ops for an unregistered permission.** `void`, no
|
||
throw, no log. The console `oxide.grant` at least answers `Permission 'x' doesn't exist`; the API
|
||
path R2 uses says nothing at all. This is the finding with teeth — see R2.
|
||
2. **A permission exists only because a loaded plugin registered it.** Kits registers `kits.admin`
|
||
and, dynamically, **every kit's `RequiredPermission`** (`Kits.cs:1225`, `:2895`) — which is what
|
||
makes R16's entitlement model real. Unload Kits and those names stop existing.
|
||
3. **`RegisterPermission` warns about a foreign prefix but registers anyway.**
|
||
`Missing plugin name prefix 'rgprobe' for permission 'someplugin.vip'` is a warning, not a
|
||
refusal — the permission was created and granted successfully. So the site *can* make a grant
|
||
stick for a plugin that is not currently loaded, at the cost of a console warning. Whether it
|
||
*should* is a phase 7 decision; the mechanism exists.
|
||
4. **A player who has never connected is in no group, but can hold direct grants.** A grant to an
|
||
unseen SteamID64 works and reads back immediately. Group membership does not exist for them yet,
|
||
so **anything the site expresses as group membership does not reach a player until their first
|
||
connection**, while a direct grant does. R16's offline entitlement is safe; a group-shaped
|
||
entitlement is not.
|
||
|
||
Point 4 is the one to carry into phase 7's design: grants and groups have **different reach** for
|
||
offline players, and the site's model currently treats them as two spellings of the same thing.
|
||
|
||
### 12.3 R5's claim about the Clans plugin was a grep artefact
|
||
|
||
Corrected in R5. The plugin raises nine hooks, not three, and three of them carry full member lists;
|
||
the six that were missed are invisible to a literal search because the hook name is a `const` at the
|
||
call site. The decision stands on a different reason — first-party is what every server has, the
|
||
plugin is optional — and phase 17 gains event-driven leadership as a sharpening rather than a
|
||
replacement.
|
||
|
||
Two smaller things from the same read, both worth having before phase 9 and 17:
|
||
|
||
- **`Clans` raises the same hook name twice per transition**, once Rust-typed
|
||
(`string, ulong, List<ulong>`) and once Universal-typed (`string, string, List<string>`), plus two
|
||
deprecated arities. Oxide binds by name **and** arity, and both live forms are arity 3 — so a
|
||
loosely typed subscriber catches both and double-counts every join and leave. Type the parameters
|
||
precisely and pick one.
|
||
- **`Clans` calls `API_RegisterThirdPartyTitle` itself.** R15's BetterChat integration will be the
|
||
*second* title provider on any server running both, not the first.
|
||
|
||
Also confirmed, since the plan rests on it: the first-party set is exactly the **seven** hooks in
|
||
`agent/hooks.tsv`, all "no return behavior", with no promote and no leader-changed.
|
||
|
||
### 12.4 ZoneManager's owner scoping is narrower than R17 assumed
|
||
|
||
Corrected in R17. `EraseTemporaryZone(owner, id)` refuses only when the zone has a *different*
|
||
owner; an **unowned** zone — every permanent zone, including every zone an operator made by hand — is
|
||
erased by anyone and returns `true`. Phase 12 must gate erasure on its own id map.
|
||
|
||
Three more things the source and the live rig agreed on:
|
||
|
||
- **ZoneManager's entire API is plain private methods**, no `[HookMethod]` anywhere in 3.1.14 — so
|
||
`Call()` by name is the only way in, and a typo is silence. Confirmed working live for
|
||
`CreateOrUpdateZone`, `CreateOrUpdateTemporaryZone`, `EraseTemporaryZone`, `GetZoneIDs` and
|
||
`GetPlayersInZone`. The three-conventions finding holds: Kits declares `[HookMethod]` (23 of them),
|
||
ZoneManager declares nothing, BetterChat will use `API_` prefixes.
|
||
- **`GetPlayersInZone` cannot distinguish an unknown zone from an empty one** — both return an empty
|
||
list, not null. The participation ledger R17 wants to feed therefore cannot use this call alone to
|
||
answer "is this zone still there", and must check `GetZoneIDs` separately. This is the same
|
||
absence-of-an-answer / answer-of-absence trap earlier phases of other workstreams hit.
|
||
- **NPCs never appear in a zone's player list.** `baseEntity is BasePlayer { IsNpc: false }` routes
|
||
them to the zone's *entity* list instead. Useful to know before designing a condition that counts
|
||
"players at the monument" on a server with scientists.
|
||
|
||
### 12.5 The one criterion phase 0 could not close, and why
|
||
|
||
> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it
|
||
|
||
The first half is **done** — proven for both an online-known and a never-seen player, in both
|
||
directions, through the same `UserHasPermission` call every third-party plugin makes.
|
||
|
||
The second half is **open, and not by choice of method.** A plugin's own permission check reads:
|
||
|
||
```csharp
|
||
private bool HasPermission(ConsoleSystem.Arg arg, string perm)
|
||
{
|
||
BasePlayer player = arg.Player();
|
||
return !player || permission.UserHasPermission(player.UserIDString, perm);
|
||
}
|
||
```
|
||
|
||
`!player ||` — a command from RCON has no `BasePlayer`, so **the server console is unconditionally
|
||
allowed**. This is the standard Oxide idiom, not a ZoneManager quirk, and it means no console session
|
||
can ever observe a gate closing. Zone occupancy has the same shape from the other end: NPCs are
|
||
excluded by construction, so nothing but a real connected player puts a name in that list.
|
||
|
||
Everything up to the body was proven: a zone was created through the API, listed by both the probe
|
||
and ZoneManager's own `zone_list`, read for occupancy (a real empty list, so the call resolved), and
|
||
erased. What remains is one person, connected, standing inside it.
|
||
|
||
**This is a standing property of the rig, not a phase-0 leftover.** Any phase whose acceptance
|
||
depends on a permission actually *refusing* somebody, or on presence, needs a client attached. Worth
|
||
knowing at phase 7 and phase 12 rather than discovering there.
|
||
|
||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|