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. Same omission for engagement: R7 settled that the set ships and nothing said what goes in it. Two new sections fix both. EVENTS.md section H is a Rust/Oxide compatibility section that already sketched the event half, and it should have been read before the phase list was written. Its thesis 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. 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 - an argument for expecting them to work, never a substitute for verifying each key live. Section 9 declares four budget dimensions, five option sources, seven leases and four actions, taking section H's ids rather than inventing a parallel set. Two things in it are load-bearing. Caps are PER RUN, and R8 makes that matter: run.scope is part of a run's unique key, so one definition fanning out to six servers is six separate budgets rather than one shared pool. An operator setting a cap of 30 is setting it per server. And rust.group.membership is the lease section H names that R16 did not - the pair is the whole reward design. A permanent earned entitlement is an ACTION with reversible: ledger (R16). A time-limited group is genuinely core.lease, held with a deadline the game enforces on its own. Same permission mirror, two shapes, and choosing wrong is the mistake: a weekend VIP implemented as a grant is a VIP for ever if the website goes away. Section 10 is the engagement catalogue - eleven triggers with their ceilings, three audiences, and the seed grouping. The ceiling lattice is containment and not size, so every ceiling is chosen against that rather than against a ladder. rust.base.destroyed is the one that matters most and is 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 is useless to the person who needs it; ceilinged everyone it broadcasts base locations to the server. 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 section 5 already flags these as admin-channel-only on the live feed and the same judgement binds their triggers. One design note flagged rather than decided: PopupNotifications gives the module an IN-GAME alert surface, which is not one of core's channels. An in-game popup is the module publishing to its own surface off its own trigger, not a fourth channel core learns about. A raid alert that reaches a phone and pops on screen next login is two mechanisms and only one of them is core's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
53 KiB
module-rust — the plan
Status: approved in outline 2026-09-15, not started. Sixteen decisions of record, no open questions. Audited against the whole contract, not just the game-facing chapters (§7).
The dry run 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 is the contract,
MODULE_SYSTEM.md the system, EVENTS.md
the event design of record, and the Integration 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 |
Module-uo |
Routes, schema fragment, prebuilt client chunk, nav |
| Sidecar | RunicGateway/Rust-Link |
link |
Owns the game connection and the durable copy |
| Oxide bridge plugin | RunicGateway/Rust-Plugins |
servuo-plugins |
C# inside the game, dials out, never blocks |
| (event capability) | — | — | Declarations inside Module-Rust (kit ch. 5) |
All three were created empty on 2026-09-15. The module is built from
integration-kit/template/, 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_idon 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.grantat the console is drift, and drift is surfaced to an operator — the same posture a lease'srestore()takes when it finds a value a human has moved (kit ch. 5).
This is a direction the Integration Kit has no chapter for, and that is a finding. Chapters 3 and 4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a teardown. This is neither: it is continuously reconciled state where the website is authoritative, and its nearest relative in the contract is the Team provider inverted — instead of core asking the module what the game knows, the module tells the game what the site knows. The mechanism it borrows is chapter 4's: every board's current state has exactly one producer, and it runs on connect, pointed the other way. Whether this deserves a sixth chapter is a question for phase 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 ch. 5 that this depends on and are easy to get wrong:
- The reason must be in
error. Core reads exactlyok,retryanderroroff a failure envelope; a message under any other name is dropped and the operator sees a bare"<action id> refused". retry: falsehas to be reachable. Core's dispatcher enforcesbudgetMsand classifies a budget timeout as retry unconditionally — so if the sidecar client's timeout is longer thanbudgetMs, our ownretry: falseis 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 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—OnClanCreated,OnClanDisbanded,OnClanMemberAdded,OnClanMemberKicked,OnClanMemberLeft, plus colour and logo — each handing you aLocalClan. All seven are "no return behavior", which is exactly what a read-only bridge wants: there is nothing to abstain from, so 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.mdis 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. And it raises exactly three
hooks — OnClanCreate, OnClanChat, OnAllianceChat — none of which is a membership
transition.
So it cannot answer any of core's three provider questions from its published surface, while first-party clans answer all three. Feeding the Team provider from first-party clans is therefore 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 is Kits, Clans and PopupNotifications, all k1lly0u
Named 2026-09-15 (org lead). All three are MIT, all by the same author, all fetched at plan time:
| Plugin | Version | Released | Source |
|---|---|---|---|
| Kits | 4.4.9 | 2026-06-04 | https://umod.org/plugins/Kits.cs |
| Clans | 0.2.10 | 2026-05-06 | https://umod.org/plugins/Clans.cs |
| PopupNotifications | 0.2.1 | 2026-08-09 | https://umod.org/plugins/PopupNotifications.cs |
Every one has a direct .cs download, so phase 0's install step is three curls into
oxide/plugins/ and no manual retrieval. Pull them fresh rather than using the copies staged in
Downloads, which are an older vintage.
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'sRequiredPermissionand 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 callGiveKitdirectly — 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.
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:
ceilingis required, has no default, and is not a ladder. Astaffceiling does not permitowner, because "one person" for a cheat-detection event is the player it was detected on. Fewer people is not less exposure.subjectKeymust 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 sharingundefined.- 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).
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>.mapbeside the world save, regenerated only on a wipe. That is exactly 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.
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 (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.
GiveKitneeded a connectedBasePlayer, 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. It has been booted, it has a generated world
(procedural, seed 1234, size 4000, save v287) and Oxide 2.0.7585 matched to its build, and its
Oxide permission store already holds a default and an admin group with one admin user — which
means R2's mechanism can be exercised on day one.
Two traps recorded here because both cost time before they were understood:
D:\rust\start.batupdated the wrong directory — fixed 2026-09-15. It ransteamcmd +force_install_dir c:\rustserver\ +app_update 258550and then launchedD:\rust\RustDedicated.exe. The server that boots had never been updated by its own script, which is why a second, never-booted install exists atC:\rustserverand whyD:\rustis a wipe behind. Now reads+force_install_dir d:\rust\; the original is kept atD:\rust\start.bat.bak.D:\rust\steamapps\appmanifest_258550.acfwas already present at the same buildid, so the first corrected run is a delta to the current wipe rather than a 5.9 GB re-download.C:\oxide_filesis a 2025-04-23 Oxide and must not be copied anywhere. Oxide ships a patchedAssembly-CSharp.dll; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it over a real install is a hard downgrade.D:\rustis 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–7 are the permissions 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. Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set (R6), prove a console grant reaches a plugin | docs | A current server boots with Kits, Clans and PopupNotifications loaded and oxide.grant demonstrably gates something |
| 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 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; 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 |
| 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 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 |
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 |
| 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 | 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 |
| 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 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§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 isletmeinin plaintext withrcon.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 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 §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/dome time held |
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) |
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; needs a zone plugin, so it belongs in the optional tier (R15) rather than the base |
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 §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.