docs(modules): the module-rust plan — 18 decisions of record and a 21-phase schedule #249

Merged
whitlocktech merged 10 commits from docs/rust-module-plan into main 2026-09-15 17:41:17 +00:00
Member

What & why

The dry run designed module-rust on paper and deliberately did not build it. This is the document that builds it: docs/modules/rust/PLAN.md, ~1,000 lines — eighteen decisions of record, a 21-phase schedule across five repos, an event catalogue and an engagement catalogue.

Three repos were created empty for it today: Module-Rust, Rust-Link, Rust-Plugins.

The decisions

R1 Identity is an in-game link code for v1 — there is still no registerAuthProvider at MODULE_API_VERSION 1.10.0
R2 Site-authored permissions mirror into Oxide's own permission store; Oxide is an enforcement cache, the site is what remembers
R3 The Kits reward action always registers and refuses with a reason in error
R4 Rust reaches an operator through the shipped installer behind --game servuo|rust
R5 Teams come from Rust's first-party clans; the uMod Clans plugin is the richer tier
R6 + R17 Base set: Kits, Clans, PopupNotifications, ZoneManager — all MIT, all with direct .cs downloads
R7 The notifications/engagement set ships in v1, as one matched phase
R8 Multi-server from the start
R9 A live map with every layer operator-toggleable; the only asset-bridge work in scope
R10 The Android app is in this workstream, capability-driven, trailing by one phase
R11 A small read-only Discord slash-command set
R12 Per-wipe detail plus all-time rollups
R13 Two extension slots: admin.users.detail and site.footer.status
R14 /rust on all three tiers
R15 An optional-integration tier, BetterChat first (leaderboard-earned chat titles)
R16 Event rewards grant the RIGHT to redeem, not the items
R18 Plugin configuration editable from the site, with a generated form and an auto-reload

Four findings worth the review time

R2 is a direction the Integration Kit has no chapter for. Chapters 3–4 are the read path; chapter 5 is ledgered one-shot commands. Site-owned permissions is neither — continuously reconciled state where the website is authoritative. Its nearest relative in the contract is the Team provider inverted. Whether that earns a sixth chapter is phase 19's question.

R5 reversed its own reasoning when the source was read. For Teams the uMod Clans plugin is worse than first-party, not richer: fifteen [HookMethod]s and every one is a mutation, no read API at all, and three hooks raised — none a membership transition. It cannot answer core's three provider questions. First-party is permanent for the provider, not a first step.

R16 dissolved a problem rather than solving one. GiveKit needs a connected BasePlayer, so an event firing at 03:00 rewarded only whoever was online. Granting the kit's RequiredPermission instead means an entitlement waits — which removes the pending-grant queue entirely, makes it the same machinery as R2, and makes reversible: 'ledger' honest where a direct grant could only ever be 'none'. The action had been declared around the wrong noun.

The contract audit (§7) cost twelve phases. The first draft was built from the game-facing chapters and under-planned the website module badly — the template makes eight of the ten non-event registrations and the plan covered three. purge.sql, the mount prefixes, the swagger fragment, the vite alias mechanism, checkImports, declareModuleSlot and the entire packaging and release path were all missing.

How it was tested

Documentation only — no code. Every factual claim was verified against a source rather than recalled:

  • The contractregisterAuthProvider absent at 1.10.0, and the ten-plus-four registration list, checked against MODULE_API.md and integration-kit/template/server/index.js.
  • The plugins — Kits 4.4.9, Clans 0.2.10, PopupNotifications 0.2.1, ZoneManager 3.1.14 and BetterChat 5.2.15 downloaded from uMod and read. The GiveKit return contract, Kits' RequiredPermission model, Clans' missing read API, ZoneManager's EraseTemporaryZone(owner, …) and BetterChat's API_RegisterThirdPartyTitle all come from the source.
  • The hooksOnPluginLoaded / OnPluginUnloaded, the seven first-party Clan hooks and the twelve Team hooks, from the mirror in docs/modules/rust/.
  • Core's endpoints/api/v1/public/modules vs /api/v1/public/status vs /api/health, checked in website/server.
  • The installer's couplingservice.rs and main.rs mention ServUO zero times; OverlayComponent and ServUoCompat name the game in the bundle schema.
  • The rigD:\rust, Oxide 2.0.7585, world save present, oxide/plugins/ empty. Its start.bat was fixed in passing: it updated C:\rustserver and launched D:\rust, so the server that boots had never been updated by its own script.

The plan is not started; nothing here changes behaviour.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

## What & why The [dry run](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md) designed `module-rust` on paper and deliberately did not build it. This is the document that builds it: **`docs/modules/rust/PLAN.md`**, ~1,000 lines — eighteen decisions of record, a 21-phase schedule across five repos, an event catalogue and an engagement catalogue. Three repos were created empty for it today: [`Module-Rust`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust), [`Rust-Link`](https://gitea.whitlocktech.com/RunicGateway/Rust-Link), [`Rust-Plugins`](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins). ### The decisions | | | |---|---| | **R1** | Identity is an in-game link code for v1 — there is still no `registerAuthProvider` at `MODULE_API_VERSION` 1.10.0 | | **R2** | Site-authored permissions mirror into Oxide's own permission store; Oxide is an enforcement cache, the site is what remembers | | **R3** | The Kits reward action always registers and refuses with a reason in `error` | | **R4** | Rust reaches an operator through the **shipped installer** behind `--game servuo\|rust` | | **R5** | Teams come from Rust's **first-party** clans; the uMod Clans plugin is the richer tier | | **R6 + R17** | Base set: **Kits, Clans, PopupNotifications, ZoneManager** — all MIT, all with direct `.cs` downloads | | **R7** | The notifications/engagement set ships in v1, as one matched phase | | **R8** | Multi-server from the start | | **R9** | A live map with **every layer operator-toggleable**; the only asset-bridge work in scope | | **R10** | The Android app is in this workstream, capability-driven, trailing by one phase | | **R11** | A small read-only Discord slash-command set | | **R12** | Per-wipe detail plus all-time rollups | | **R13** | Two extension slots: `admin.users.detail` and `site.footer.status` | | **R14** | `/rust` on all three tiers | | **R15** | An optional-integration tier, BetterChat first (leaderboard-earned chat titles) | | **R16** | **Event rewards grant the RIGHT to redeem, not the items** | | **R18** | Plugin configuration editable from the site, with a generated form and an auto-reload | ### Four findings worth the review time **R2 is a direction the Integration Kit has no chapter for.** Chapters 3–4 are the read path; chapter 5 is ledgered one-shot commands. Site-owned permissions is neither — continuously reconciled state where the website is authoritative. Its nearest relative in the contract is the **Team provider inverted**. Whether that earns a sixth chapter is phase 19's question. **R5 reversed its own reasoning when the source was read.** For Teams the uMod Clans plugin is *worse* than first-party, not richer: fifteen `[HookMethod]`s and **every one is a mutation**, no read API at all, and three hooks raised — **none a membership transition**. It cannot answer core's three provider questions. First-party is permanent for the provider, not a first step. **R16 dissolved a problem rather than solving one.** `GiveKit` needs a connected `BasePlayer`, so an event firing at 03:00 rewarded only whoever was online. Granting the kit's `RequiredPermission` instead means an entitlement waits — which removes the pending-grant queue entirely, makes it the same machinery as R2, and makes `reversible: 'ledger'` honest where a direct grant could only ever be `'none'`. The action had been declared around the wrong noun. **The contract audit (§7) cost twelve phases.** The first draft was built from the game-facing chapters and under-planned the website module badly — the template makes eight of the ten non-event registrations and the plan covered three. `purge.sql`, the mount prefixes, the swagger fragment, the vite alias mechanism, `checkImports`, `declareModuleSlot` and the **entire packaging and release path** were all missing. ## How it was tested Documentation only — no code. Every factual claim was verified against a source rather than recalled: - **The contract** — `registerAuthProvider` absent at 1.10.0, and the ten-plus-four registration list, checked against `MODULE_API.md` and `integration-kit/template/server/index.js`. - **The plugins** — Kits 4.4.9, Clans 0.2.10, PopupNotifications 0.2.1, ZoneManager 3.1.14 and BetterChat 5.2.15 downloaded from uMod and read. The `GiveKit` return contract, Kits' `RequiredPermission` model, Clans' missing read API, ZoneManager's `EraseTemporaryZone(owner, …)` and BetterChat's `API_RegisterThirdPartyTitle` all come from the source. - **The hooks** — `OnPluginLoaded` / `OnPluginUnloaded`, the seven first-party Clan hooks and the twelve Team hooks, from the mirror in `docs/modules/rust/`. - **Core's endpoints** — `/api/v1/public/modules` vs `/api/v1/public/status` vs `/api/health`, checked in `website/server`. - **The installer's coupling** — `service.rs` and `main.rs` mention ServUO zero times; `OverlayComponent` and `ServUoCompat` name the game in the bundle schema. - **The rig** — `D:\rust`, Oxide 2.0.7585, world save present, `oxide/plugins/` empty. Its `start.bat` was fixed in passing: it updated `C:\rustserver` and launched `D:\rust`, so the server that boots had never been updated by its own script. The plan is not started; nothing here changes behaviour. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 10 commits 2026-09-15 17:37:12 +00:00
The dry run designed this module on paper and deliberately did not build it.
This is the document that builds it: eleven phases, three new repos, and the
three decisions the org lead settled on 2026-09-15.

R1 identity is an in-game link code for v1. There is still no
registerAuthProvider at MODULE_API_VERSION 1.10.0, so "Sign in with Steam" is
not reachable from a module. What changed since the dry run is the stakes, not
the options: the dry run rated this survivable because the module only read,
and R2/R3 make the site the author of who may do what and the thing that hands
out loot. A weak link is now a privilege-escalation path.

R2 site-authored permissions mirror into Oxide's own permission store, so every
third-party plugin honours them with no adapter and a wipe stops being a
data-loss event for permissions. This is a direction the Integration Kit has no
chapter for - not the read path, not a ledgered one-shot, but continuously
reconciled state where the website is authoritative. Its nearest relative is the
Team provider inverted. Whether that deserves a sixth chapter is phase 10's
question.

R3 the Kits reward action always registers and refuses with a reason in `error`,
rather than vanishing from the form or refusing to boot. Phase 8 must declare
reversible: 'none' honestly - there is no way to un-grant loot a player has
spent - and count cost() per kit actually granted.

The rig is D:\rust, which has been booted and carries a matched Oxide 2.0.7585.
Two traps recorded: its start.bat updates C:\rustserver and launches D:\rust, so
the server that boots has never been updated by its own script; and
C:\oxide_files is a 2025-04-23 Oxide whose bundled Assembly-CSharp.dll would
downgrade a real install.

One question left open: which plugins besides Kits are in the required base set.
oxide/plugins/ is empty, so all of it is a fresh install either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
R4 - Rust reaches an operator through the shipped installer behind
--game servuo|rust, not a second binary and not a shared-core refactor. The
shape of the work is set by where the coupling already is: service.rs and
main.rs mention ServUO zero times, while install.rs, doctor.rs, overlay.rs and
tier.rs carry nearly all of it, plus the bundle manifest where OverlayComponent
and ServUoCompat name the game in the schema itself. So the game payload becomes
a variant and ServUoCompat a per-game compat block - a schema change on the
published bundles branch, which is the part to design before touching code. The
Rust payload is much simpler than the UO one (no source tree, no patches, no
patch tier - one .cs into oxide/plugins) and what it gains instead is an Oxide
prerequisite check, which is doctor's shape rather than a new concept.

R5 - Teams come from Rust's FIRST-PARTY clans; the uMod Clans plugin is the
richer optional tier in phase 9. Three distinctions that are easy to collapse
and expensive to get wrong: the seven first-party clan hooks are all "no return
behavior", which is exactly what a read-only bridge wants; Rust's first-party
TEAMS are a different system entirely (twelve mostly-vetoable hooks, the
transient squad rather than the persistent organisation) and are not what core's
Team provider should be fed; and the uMod Clans plugin's API is not in our
mirror at all, since HOOKS.md is the game's 477 hooks and a plugin's API is its
own documentation.

The gap R5 has to design around: the seven hooks carry created, disbanded,
added, kicked and left, but no promote or leader-changed event. So
getTeamLeaders reads leadership off LocalClan at snapshot time, which makes
phase 6 partly snapshot-driven where the dry run predicted fully event-driven.
Recorded as a correction to that document rather than silently.

Also records the start.bat fix on the rig: it now updates D:\rust rather than
C:\rustserver, the original is kept at start.bat.bak, and the appmanifest
already present at the same buildid means the first corrected run is a delta
rather than a 5.9 GB re-download.

Phases are now 0-11; the installer is phase 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
The three repos exist and are named: Module-Rust, Rust-Link, Rust-Plugins. Notes
that a repository name is not a module id - Module-uo ships id `uo`, this ships
`rust`, and 2.1 requires id to equal the directory core loads it from.

R6 - the required base set is Kits 4.4.9, Clans 0.2.10 and PopupNotifications
0.2.1, all k1lly0u, all MIT, each with a direct .cs download, so phase 0's
install step is three curls rather than manual retrieval. Clans is listed as a
Universal plugin, written against Covalence, which is why its API takes IPlayer
rather than BasePlayer.

Kits is a good dependency: 23 HookMethods, including GiveKit for the reward
action, GetKitNames/GetAllKits for the authoring form's OPTION SOURCE so an
operator picks a kit from the live server instead of typing an identifier, and
per-player uses and cooldown for eligibility. It also raises OnKitRedeemed,
which the bridge can report as an ordinary event whoever triggered it.

Two traps in GiveKit, both found by reading it rather than reasoning about it.
It returns null on a failure path - `if (!player) return null` - and null is
Oxide's idiom for "no opinion", so the reflex of treating null as fine reports a
reward as delivered when there was no player to deliver it to. Success is the
literal true and a refusal is a message string, which drops straight into
chapter 5's `error`. And GiveKit takes a BasePlayer, so there is no offline
grant: an event rewarding participants at 2am rewards only whoever is online.
Phase 8 chooses between accepting online-only and keeping a persisted
pending-grant queue, which is a second at-most-once store and is not free.

R5 is settled far more firmly, and the other way round from how it was argued.
For Teams the Clans plugin is WORSE than first-party, not richer: it publishes
fifteen HookMethods and every one is a mutation, with no read API at all - no
GetClan, no GetClanMembers, no GetAllClans - and it raises exactly three hooks,
none of them a membership transition. It cannot answer any of core's three
provider questions from its published surface, while first-party clans answer
all three. So first-party is PERMANENT for the provider, not a first step. What
the plugin actually adds is alliances and clan/alliance chat - richer in
features, not in roster data - which is what phase 9 surfaces beside the
provider rather than under it. Reading its own data files is recorded as
deliberately not taken: that is another plugin's private storage, not an API.

Two questions left open in 3, neither blocking: whether Clans being in the base
set while the provider reads first-party is the intended reading (an
interpretation, not something stated), and the offline-grant choice, deferred to
phase 8 on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
The first draft of the phase list was built from chapters 3-5 and under-planned
the website module by a wide margin. The template registers eight of the ten
non-event registrations; the plan covered three.

Unplanned and now recorded: registerExtension, registerNotificationStreams,
registerEventTriggers, registerAudiences, registerEngagementSeeds,
registerAnnounceLeg, registerPostHook, registerSlashCommands - with triggers,
audiences and seeds being a matched set rather than three independent gaps.
Also unplanned: purge.sql, mount prefix choice, the swagger fragment and its
staleness check, the vite alias/shim mechanism the kit calls the highest-risk
mechanical detail in the system, checkImports, and the entire packaging and
release path - there was no packaging phase at all.

On the client: declareModuleSlot, which the kit says is needed the moment a game
has anything like a guild, so it belongs in the Teams phase; registerFeatureProvider;
and the UI kit discipline that has caught this project twice already (PublicLayout
needs shell, PageHeader silently drops unknown props).

Beyond the module: the asset bridge, which Rust's item icons and 2,590 skin ids
fit exactly; the .profile landing page, owed whenever repos are added and three
just were; runicgateway.com, for which a second game is a headline change; and
the Android app, which feature-detects capabilities and must render a site whose
module it has never heard of.

Section 8 carries the eight questions the plan cannot answer for itself.
Every question the contract audit raised was answered the same day. Eight new
decisions of record, and the schedule goes from twelve phases to twenty.

R7 the notifications and engagement set ships in v1 - streams, triggers,
audiences, seeds, announce leg, post hook - as ONE phase because they are a
matched set in the template rather than three independent gaps. The three rules
it lives on: ceiling is required 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); subjectKey must name a declared variable or every subject
shares undefined; and a rule group is offered once, so a rule appended to an
existing group reaches fresh installs only.

R8 multi-server from the start - the dry run's finding 2 taken at face value.
R12 per-wipe detail plus all-time rollups, with the truncation as a runtime
route and never a schema statement, since the fragment replays every boot.
R14 /rust on all three tiers, chosen deliberately because prefixes share one
namespace with core's and the collision probe cannot see core's root-mounted
endpoints. R13 two extension slots. R11 a small read-only slash command set,
with ephemerality fixed at the deferral so a refusal must defer ephemeral.

R9 is the one that split in two. The map IMAGE is static content on the game
host regenerated only on a wipe - the ch.3 2b case, so request/reply, one in
flight, two stages, its own derivation version, and no import on boot - while
everything moving on it is live state down the ordinary read path. Every layer
is an operator switch, and that is a security boundary rather than a preference:
public player positions in Rust locate players and let anyone infer base
positions. Default is monuments and world events public, players and bases
admin-only. This is the ONLY asset-bridge work in scope; item icons and the
2,590 skin ids stay out of v1.

R10 the Android app is in this workstream, deciding its screens from
/api/v1/public/modules capabilities, with each leg trailing the website surface
it consumes by one phase. Records the two endpoints that are not this and are
easy to confuse with it: /api/v1/public/status is site mode plus a version
block, and /api/health is an internal liveness probe.

Section 7 now carries the phase each previously-unplanned element lands in, and
the honest cost: twelve phases, from 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 module is most of the work.
R16 changes a design rather than adding to it. The reward action no longer calls
GiveKit; it grants the permission that GATES a kit, and the player redeems it
themselves in game. Kits already has exactly this model built in - every kit
carries a RequiredPermission, GiveKit's own path checks it, the in-game menu
renders an ungated kit as locked rather than hiding it, and GetKitInfo returns
the permission so the module can read which kits are gated.

What that removes is most of the hard part.

The offline-grant problem disappears. GiveKit needed a connected BasePlayer, so
an event firing at 2am rewarded only whoever was online; an entitlement waits.
That CLOSES the open question section 3 carried - no pending-grant queue, no
second at-most-once store.

It is the same machinery as R2 rather than a second mechanism: a reward becomes
a permission grant authored by the site and mirrored into Oxide, which 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'. revert revokes the permission, removing one that is not there is a
success, and it is idempotent by construction. A player who redeemed before the
revert keeps the items, and that is correct: the ledgered resource is the GRANT,
so reverting withdraws the entitlement rather than the consumption.

cost() counts grants and is exactly knowable before dispatch, which removes the
whole declare-the-maximum-because-you-cannot-know class of problem from chapter
5 section 4. And the idempotency key largely stops mattering: chapter 5 draws
the line itself - a key is for a write whose repetition would be a second
EFFECT, and a permission grant is a SET.

The earlier section arguing a kit grant can only be reversible: none is
rewritten rather than deleted, with the correction stated: it was right about a
direct grant, and the reusable part is that the action had been declared around
the wrong noun. What the event makes is not loot, it is an entitlement.

One design note carried into the option source: a kit with an EMPTY
RequiredPermission is open to everybody, so granting a permission for it rewards
nobody. The dropdown must surface which kits are gated and refuse or warn on one
that is not.

R15 opens an optional-integration tier, with BetterChat (LaserHydra, 5.2.15,
MIT, Universal) as the first member, for leaderboard-earned chat titles. Its
integration point is a PULL - API_RegisterThirdPartyTitle registers a callback
BetterChat invokes per player - so a title is a pure function of state we
already hold, with nothing written into it and no drift to reconcile. The trap
is chapter 4's rule applied to somebody else's callback: that getter runs
synchronously on the chat path and must be a cheap in-memory lookup, never a
socket call. Its API_AddGroup and API_SetGroupField pair naturally with R2's
site-authored groups, but that direction is a push and would need R2's drift
posture, so it is a phase 17 decision rather than a given.

Section 3 is now empty. Clans-in-the-base-set was confirmed complementary, and
the offline-grant question was dissolved rather than answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
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
ZoneManager (k1lly0u, 3.1.14, MIT, ~218k downloads) is a fourth REQUIRED plugin
rather than an optional one. It is what makes an event able to answer where a
player is, and reading its source changed two things this plan had been vague
about while promoting one action out of the optional tier.

Its API is private methods reached through Oxide's reflection Call() - no
HookMethod, no API_ prefix. That makes THREE conventions among the four base
plugins: Kits declares HookMethod, BetterChat uses API_ prefixed methods, and
ZoneManager uses plain private methods resolved by name. All are reached the
same way from our side, but only the first is greppable as a declared API, which
is worth knowing before someone goes hunting for one that is not there.

Participation stops being the hard part. EVENTS.md section 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. That is where an action's participants envelope member gets its content.

Advance conditions become expressible. A phase that waits until ten players are
at the monument is a real gate that reads zone membership, rather than distance
arithmetic against a point recomputed on a timer.

And rust.zone.open moves from the optional tier into the base catalogue with an
honest reversible: ledger. 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. That is most
of the persisted ownership registry chapter 4 demands - we still keep our own
map from core's resource reference to the zone id, but we are borrowing a
concept rather than inventing one. Erasing a zone that is gone is a success,
which is what revert needs.

One trap recorded, chapter 4's rule meeting a chatty hook: OnEnterZone and
OnExitZone fire on the game thread and a large zone on a busy server produces a
great many. 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 at phase 12 with the hooks in front of you, and
measure it: a sweep over GetPlayerZoneIDsNoAlloc is cheap and a flood of wire
traffic is not.

Also adds rust.options.zones, makes the rust.zone.minutes budget dimension real,
and takes phase 0's install step to four curls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
An admin edits any loaded plugin's configuration from the website and it reloads
automatically. Base tier generates a form from the config VALUES themselves -
boolean to toggle, number to numeric field, string to text, array to list,
nested object to group - so it works for whatever plugins happen to be
installed, including ones added after we ship. Advanced tier is raw JSON.

Same posture as R2, the site as authority over the game host, but a different
SHAPE: R2 is continuously reconciled state pushed on connect, this is
request/reply on demand. It must not be built on the permission mirror.

The mechanics, all verified: configs at oxide/config/<Plugin>.json, oxide.reload
rereads one, and OnPluginLoaded / OnPluginUnloaded are real hooks in the Server
category - so whether a reload actually SUCCEEDED is observable rather than
assumed. That is what makes the feature safe.

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 of
{"Rate":1.0} yields the number 1 and JSON.stringify writes it back as 1, so a
naive read-modify-write 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 textually, 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 are recorded, since the
feature's whole promise is that it works without knowing the plugin: empty
arrays and null carry no type; enum-like strings are indistinguishable from free
text; there are no descriptions, minimums or maximums, so the key name is the
entire label; nested objects need recursion with a depth limit and a raw-JSON
fallback; and the file after a reload may not be what we wrote, because Oxide
merges missing defaults and saves.

Safety needs more than usual here, because a bad config does not fail the write,
it fails the next LOAD and the plugin stays down - and R6/R17 make four plugins
required, so a broken ZoneManager config takes event participation with it. The
write path is: read with a version and require it back on write so a concurrent
on-disk edit conflicts rather than being clobbered; validate it parses; back up,
write, reload; then watch for OnPluginLoaded within a window and, if it does not
arrive, restore the backup and reload again AUTOMATICALLY. 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. Plugin configs routinely hold API keys and Discord
webhooks, so a config reader hands those to anyone who can open the page: mask
values whose key matches key/token/secret/password/webhook and treat them
write-only, as the platform already treats the uo-link token. And gate it on its
own site permission with an audit trail of who changed which key from what to
what and whether the reload succeeded - it is an admin writing to the game
host's filesystem, the most powerful thing the site can do to a server.

One distinction kept explicit: editing a config FILE is not a lease. A lease
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 an event should never reach for this one.

Lands as phase 7b, beside permissions, since it shares the admin surface and the
gating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
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 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 there 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, and Clans
keeps clan_data.json with 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. Different problem, different answer, deliberately
out of scope.

The reload target cannot be inferred from the path. oxide/config/Foo/bar.json
may belong to plugin Foo or to something else; the folder name is convention,
not contract. So the target is an explicit field with the folder name as its
default guess. 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 a path-traversal surface. Canonicalise the
resolved path, assert it is under the config root, reject absolute paths, reject
symlinks resolving outside. Before this amendment the feature addressed files by
plugin name; addressing them by path is exactly the change that introduces the
bug class.

And bound it: depth limit, file-count limit, per-file size cap - a pathological
tree must not be enumerated and a multi-megabyte JSON must not be loaded into a
form. Because one plugin can own several files, the backup and rollback operate
on the whole set a save touches rather than one file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit 5bc038adf8 into main 2026-09-15 17:41:17 +00:00
whitlocktech deleted branch docs/rust-module-plan 2026-09-15 17:41:18 +00:00
Sign in to join this conversation.
No description provided.