From a5881d5a5532dfacf8a86519fec04d2d67cd798c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 11:12:20 -0500 Subject: [PATCH 01/10] docs(modules): the module-rust plan, its decisions of record and its rig The dry run designed this module on paper and deliberately did not build it. This is the document that builds it: eleven phases, three new repos, and the three decisions the org lead settled on 2026-09-15. R1 identity is an in-game link code for v1. There is still no registerAuthProvider at MODULE_API_VERSION 1.10.0, so "Sign in with Steam" is not reachable from a module. What changed since the dry run is the stakes, not the options: the dry run rated this survivable because the module only read, and R2/R3 make the site the author of who may do what and the thing that hands out loot. A weak link is now a privilege-escalation path. R2 site-authored permissions mirror into Oxide's own permission store, so every third-party plugin honours them with no adapter and a wipe stops being a data-loss event for permissions. This is a direction the Integration Kit has no chapter for - not the read path, not a ledgered one-shot, but continuously reconciled state where the website is authoritative. Its nearest relative is the Team provider inverted. Whether that deserves a sixth chapter is phase 10's question. R3 the Kits reward action always registers and refuses with a reason in `error`, rather than vanishing from the form or refusing to boot. Phase 8 must declare reversible: 'none' honestly - there is no way to un-grant loot a player has spent - and count cost() per kit actually granted. The rig is D:\rust, which has been booted and carries a matched Oxide 2.0.7585. Two traps recorded: its start.bat updates C:\rustserver and launches D:\rust, so the server that boots has never been updated by its own script; and C:\oxide_files is a 2025-04-23 Oxide whose bundled Assembly-CSharp.dll would downgrade a real install. One question left open: which plugins besides Kits are in the required base set. oxide/plugins/ is empty, so all of it is a fresh install either way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 207 +++++++++++++++++++++++++++++++++++++++++ modules/rust/README.md | 5 + 2 files changed, 212 insertions(+) create mode 100644 modules/rust/PLAN.md diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md new file mode 100644 index 0000000..87f597b --- /dev/null +++ b/modules/rust/PLAN.md @@ -0,0 +1,207 @@ +# `module-rust` — the plan + +**Status:** approved in outline 2026-09-15, not started. Three decisions of record settled; one +question open (§3). + +The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. +This is the document that builds it. Where the two disagree, this one is later and wins — but the dry +run's four *findings* still stand, and one of them (identity) has moved from a footnote to the +critical path. §2 says why. + +Nothing here is normative. [`MODULE_API.md`](../../website/MODULE_API.md) is the contract, +[`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) the system, [`EVENTS.md`](../../website/EVENTS.md) +the event design of record, and the [Integration Kit][kit] is the teaching text this plan follows +chapter by chapter. This document is a *schedule and a set of decisions*, not a specification. + +--- + +## 1. The shape, and what is already settled + +Three new repositories, mirroring the three the platform already has for Ultima Online, plus the +optional fourth part that lives inside the module: + +| Part | New repo | Mirrors | What it is | +|---|---|---|---| +| Website module | `RunicGateway/Module-rust` | `Module-uo` | Routes, schema fragment, prebuilt client chunk, nav | +| Sidecar | `RunicGateway/rust-link` | `link` | Owns the game connection and the durable copy | +| Oxide bridge plugin | `RunicGateway/rust-plugins` | `servuo-plugins` | C# inside the game, dials out, never blocks | +| *(event capability)* | — | — | Declarations inside `Module-rust` ([kit][kit] ch. 5) | + +None exist yet. The module is built from [`integration-kit/template/`][kit], which CI holds against a +pinned core — currently `MODULE_API_VERSION` **1.10.0**. + +Settled before this document and unchanged by it (dry run, org lead, 2026-08-19): + +- **One server, one sidecar**, on that server's own host. A community with six servers runs six + pairs; the module holds six clients and core never learns there is more than one. +- **The plugin dials out.** Rust's server is a binary, so the way in is Oxide's published hook API + rather than source — and the shard-dials-out invariant survives that change of footing unchanged. +- **No RCON.** It was the original design and it was overruled. +- **`wipe_id` on every table that holds gameplay data.** It is the whole shape of the game in one + column, and it is the first thing a UO-shaped mental model gets wrong. + +## 2. Decisions of record + +### R1 — identity is an in-game link code for v1 + +**Decided 2026-09-15 (org lead).** A player proves account ownership by typing a command in-game; the +plugin issues a one-time code; the website confirms it through the sidecar. Exactly the shape +`module-uo` uses. + +This is the dry run's finding 1 answered for now rather than closed. There is still **no +`registerAuthProvider`** in the contract at 1.10.0 — verified against `MODULE_API.md` §2.4 on +2026-09-15 — so "Sign in with Steam", which every Rust community expects, is not reachable from a +module today. The link code is one screen worse and needs no core change, so it is what v1 ships. + +**What changed since the dry run is the stakes, not the options.** The dry run rated this survivable +because the module only *read*: a site that renders a leaderboard does not need to know which account +owns a Steam ID. R2 makes the site the author of who may do what in the game, and R3 makes it the +thing that hands out loot. Both are grants against a Steam ID. **A weak identity link is now a +privilege-escalation path, not a missing convenience** — so the code must be single-use, +short-lived, rate-limited, and issued in-game to the player who will own it. + +Adding `registerAuthProvider` properly stays the first candidate for a future `MODULE_API` bump. It +participates in session creation, which is the one part of core a module must never be able to +weaken, and it must inherit core's existing policy: **SSO is link-only, identities are never +auto-provisioned.** Specified deliberately, not bolted on. It is out of scope here. + +### R2 — site-authored permissions are mirrored into Oxide's own permission store + +**Decided 2026-09-15 (org lead).** The website is the author of record for groups and grants. The +bridge plugin applies them through Oxide's own API (`permission.GrantUserPermission` / +`RevokeUserPermission`), so **Oxide is an enforcement cache and the site is the thing that +remembers.** + +The alternative — the plugin keeping a private table only our own features consult — was rejected +because it cannot reach any third-party plugin, and reaching them is the point: a grant authored on +the site has to gate Kits. + +Three properties fall out, and they are the reason this shape is worth its cost: + +- **Every third-party plugin honours site-authored grants with no adapter**, because they all already + call `permission.UserHasPermission`. +- **A wipe stops being a data-loss event for permissions.** The game forgets; the site does not, and + re-pushes the whole set on the next connect. +- **Hand edits are reported, not overwritten.** Somebody typing `oxide.grant` at the console is + drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it + finds a value a human has moved ([kit][kit] ch. 5). + +**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and +4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a +teardown. This is neither: it is *continuously reconciled state where the website is authoritative*, +and its nearest relative in the contract is the Team provider **inverted** — instead of core asking +the module what the game knows, the module tells the game what the site knows. The mechanism it +borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on +connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 10, +after it has been built once. + +### R3 — the Kits reward action registers always and refuses with a reason + +**Decided 2026-09-15 (org lead).** Kits is required for event rewards, but "required" means the +action always exists and fails honestly where it cannot work — `{ ok: false, retry: false, error: +'Kits plugin not installed' }` — rather than vanishing from the authoring form or refusing to boot +the module. + +Two details from [kit][kit] ch. 5 that this depends on and are easy to get wrong: + +- **The reason must be in `error`.** Core reads exactly `ok`, `retry` and `error` off a failure + envelope; a message under any other name is dropped and the operator sees a bare + `" refused"`. +- **`retry: false` has to be reachable.** Core's dispatcher enforces `budgetMs` and classifies a + budget timeout as retry *unconditionally* — so if the sidecar client's timeout is longer than + `budgetMs`, our own `retry: false` is unreachable code. Derive one constant from the other and + assert the inequality in a test. The first module this project shipped had exactly that pairing. + +## 3. Open question + +**Which other uMod plugins are in the required base set?** Kits is named. The rest of "a couple" is +not, and phase 0 cannot finish its install list without it. Everything beyond the base set is +phase 9's optional tier. + +`D:\rust\oxide\plugins\` is currently **empty**, so whatever the set is, all of it is a fresh install. + +## 4. The test rig + +`D:\rust` on the org lead's workstation. It has been booted, it has a generated world +(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its +Oxide permission store already holds a `default` and an `admin` group with one admin user — which +means R2's mechanism can be exercised on day one. + +Two traps recorded here because both cost time before they were understood: + +- **`D:\rust\start.bat` updates the wrong directory.** It runs + `steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launches + `D:\rust\RustDedicated.exe`. The server that boots has never been updated by its own script. That + is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind. +- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched + `Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it + over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it. + +**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the +rig current" is a recurring question, not a one-time setup step. Every phase that touches the plugin +re-checks it. + +## 5. The phases + +Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 7–8 are events. +9 is the optional tier. Each phase ends with its findings written down, as every workstream here does. + +| # | Phase | Repos | Done when | +|---|---|---|---| +| 0 | **The rig.** Fix `start.bat`, update to the current wipe, confirm the Oxide build still matches, install the base set, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something | +| 1 | **Protocol 1 and three skeletons.** Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from `template/` | all 3 + docs | One hello line travels game → sidecar → module, and killing the sidecar does not stall the game | +| 2 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots kept distinct at the wire; `wipe_id` everywhere; every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation | +| 3 | **The first pages.** Server list, per-server status, killfeed, leaderboard; `capabilities`; nav | Module-rust | The site renders the last thing the game said while the game is off | +| 4 | **Identity** (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID | +| 5 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe | +| 6 | **Teams provider.** Event-driven with a baseline on connect — Rust delivers membership transitions in real time, so no sweep | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server | +| 7 | **Events: one budget, one lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-rust + 2 | The leased value is observed changing in the running game and restored, per key | +| 8 | **Events: the Kits reward action** (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree | +| 9 | **Optional mod integrations.** One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module | +| 10 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter answered either way | + +### Why the lease comes before the reward action + +The stated priority is Kits rewards, and this plan still schedules a lease first. [kit][kit] ch. 5 is +explicit about it and the reasoning survives restating: a lease is *a value that already existed, +changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action +makes something that did not exist. The lease proves the whole command path — correlation, the +idempotency key, `budgetMs` against the client timeout, the deadline the game enforces on its own — +before anything hands out loot. It is a cheaper place to find all four of chapter 5's invisible +failures. + +Reversible on request. + +### What phase 8 must declare honestly + +**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and +have the thing undone, on every terminal path including an abort — and there is no honest way to +un-grant loot a player has already spent. The correct declaration is `reversible: 'none'`, and saying +so is the point: a capability that claims a reversal it cannot perform is the "capability that lies" +chapter 5 names, and neither core nor review can catch it. + +`cost()` must count the kits actually granted, derived from the params, every time. Core prices +`cost` before dispatch and never reconciles it against what came back — it cannot, it does not know +what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30 +into a cap of 360 with nothing anywhere going red. + +And the monthly wipe is the case `revert`'s tolerance rule was written for: every ledgered resource +is invalidated at once, and **"gone, and that is fine" is a success**, not a failure. + +## 6. Risks worth naming now + +- **Hooks bind by name and arity, by reflection, with no compile-time check.** A misspelled hook is + never called, silently, with no warning at load — the single most common way a Rust plugin does + nothing. The plugin must log which of its expected hooks have fired at least once, so a hook + Facepunch renamed on a wipe is visible rather than mysterious. See [`README.md`](README.md) §2. +- **A convar that applies cleanly and does nothing.** Most game config is read once at boot and + cached; applying it later succeeds, reads back correctly, and changes nothing. Every lease key gets + verified live — apply, observe in the running game, restore — before it is advertised. The UO + module surveyed 156 config reads and found roughly eight that were live. +- **The wipe cadence is the schedule.** A monthly force wipe moves the hook list, rebuilds Oxide, and + invalidates every ledgered resource. Phases that end near one should expect to re-verify rather + than assume. +- **`start.bat`'s RCON password is `letmein` in plaintext with `rcon.web 1`.** Acceptable on a + loopback dev rig, and it must never be the shape anything published copies. + +[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit diff --git a/modules/rust/README.md b/modules/rust/README.md index 1098960..8b6d26b 100644 --- a/modules/rust/README.md +++ b/modules/rust/README.md @@ -16,6 +16,11 @@ Everything below was **scraped verbatim from uMod on 2026-09-15**. | [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. | | [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. | +**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record +for actually building `module-rust`. Everything else in this directory is copied from uMod; that one +is written by this project and is where the phases, the settled decisions and the local test rig are +recorded. + > **This is a mirror, not a specification we own.** uMod is upstream and wins any disagreement; the > point of copying it is availability and grep-ability, not authority. Nothing here may be cited as a > Runic Gateway contract — our contracts are [`MODULE_API.md`](../../website/MODULE_API.md) and From 7d589ff5f23b1a6e5491e81d7f1edcc6af671909 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 11:32:43 -0500 Subject: [PATCH 02/10] docs(modules): R4 the installer grows a game dimension, R5 Teams come from first-party clans 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 79 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 87f597b..c07e82c 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,6 +1,6 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. Three decisions of record settled; one +**Status:** approved in outline 2026-09-15, not started. Five decisions of record settled; one question open (§3). The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. @@ -112,6 +112,60 @@ Two details from [kit][kit] ch. 5 that this depends on and are easy to get wrong `budgetMs`, our own `retry: false` is unreachable code. Derive one constant from the other and assert the inequality in a test. The first module this project shipped had exactly that pairing. +### R4 — Rust reaches an operator through the existing installer, behind `--game` + +**Decided 2026-09-15 (org lead).** Not a second binary and not a shared-core refactor: the shipped +[`installer`](../../installer/PLAN.md) grows a game dimension, `--game servuo|rust`, and keeps one +release stream, one `doctor`, one `update`, one `uninstall`. + +The shape of the work is set by where the coupling already is. **The reusable half is already +game-agnostic** — `src/service.rs` and `src/main.rs` mention ServUO zero times, and `net.rs`, +`diff.rs`, `paths.rs` and `ui.rs` barely more. **The UO-specific half is concentrated in four files** +— `install.rs`, `doctor.rs`, `overlay.rs`, `tier.rs` — plus the bundle manifest, where +`OverlayComponent` and `ServUoCompat` name the game in the schema itself. + +So the change is: make the bundle's game payload a **variant** rather than an overlay, and +`ServUoCompat` a per-game compat block. That is a schema change on the published **`bundles`** +branch, and it is the part to design before touching code. + +**The Rust payload is much simpler than the UO one**, which is what makes this affordable: no source +tree to overlay, no `patches/`, no opt-in patch tier — a Rust install is a `.cs` file dropped into +`oxide/plugins/`, plus the sidecar and its service, which the installer already knows how to do. What +it gains instead is a **prerequisite check**: is Oxide installed, and is its build current enough. +That is `doctor`'s shape, not a new concept. + +The protocol pairing check generalises unchanged. `servuo-plugins/overlay.toml` declares the protocol +the overlay speaks and the installer refuses to pair a disagreeing sidecar; `rust-plugins` needs the +same declaration under whatever name the variant gives it, and the refusal is the same refusal. + +### R5 — Teams come from Rust's first-party clans; the uMod Clans plugin is the richer tier + +**Decided 2026-09-15 (org lead).** Basic functionality is built on **Rust's own clan system**; the +third-party **uMod Clans plugin** is an optional layer for a much richer experience, and lands in +phase 9 rather than phase 6. + +The distinction matters more than the names suggest, so it is worth stating precisely: + +- **Rust's first-party clans** are seven hooks in [`HOOKS.md`](HOOKS.md) — `OnClanCreated`, + `OnClanDisbanded`, `OnClanMemberAdded`, `OnClanMemberKicked`, `OnClanMemberLeft`, plus colour and + logo — each handing you a `LocalClan`. **All seven are "no return behavior"**, which is exactly + what a read-only bridge wants: there is nothing to abstain from, so [kit][kit] ch. 4's + return-`null`-from-every-veto rule has no work to do here. They need no plugin, and the rig is + already running them (`D:\rust\server\server1\clans.287.db`). +- **Rust's first-party *Teams* are a different system** — twelve hooks, mostly vetoable — and are the + transient in-game squad, not the persistent organisation. They are **not** what core's Team + provider should be fed. Clans is the right choice and this is why. +- **The uMod Clans plugin's API is not in our mirror.** [`HOOKS.md`](HOOKS.md) is the *game's* 477 + hooks; a plugin's own API is its own documentation. It is reached through `[PluginReference]` and + is null when absent, making it an R3-shaped dependency that must refuse with a reason. It is also + not currently staged locally and would need pulling and mirroring. + +**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 6 *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. + ## 3. Open question **Which other uMod plugins are in the required base set?** Kits is named. The rest of "a couple" is @@ -129,10 +183,13 @@ means R2's mechanism can be exercised on day one. Two traps recorded here because both cost time before they were understood: -- **`D:\rust\start.bat` updates the wrong directory.** It runs - `steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launches - `D:\rust\RustDedicated.exe`. The server that boots has never been updated by its own script. That +- **`D:\rust\start.bat` updated the wrong directory — fixed 2026-09-15.** It ran + `steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launched + `D:\rust\RustDedicated.exe`. The server that boots had never been updated by its own script, which is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind. + Now reads `+force_install_dir d:\rust\`; the original is kept at `D:\rust\start.bat.bak`. + `D:\rust\steamapps\appmanifest_258550.acf` was 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_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched `Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it. @@ -143,22 +200,24 @@ re-checks it. ## 5. The phases -Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 7–8 are events. -9 is the optional tier. Each phase ends with its findings written down, as every workstream here does. +Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 6 is Teams. 7–8 +are events. 9 is the optional tier. 10 is how any of it reaches somebody who is not us. Each phase +ends with its findings written down, as every workstream here does. | # | Phase | Repos | Done when | |---|---|---|---| -| 0 | **The rig.** Fix `start.bat`, update to the current wipe, confirm the Oxide build still matches, install the base set, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something | +| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something | | 1 | **Protocol 1 and three skeletons.** Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from `template/` | all 3 + docs | One hello line travels game → sidecar → module, and killing the sidecar does not stall the game | | 2 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots kept distinct at the wire; `wipe_id` everywhere; every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation | | 3 | **The first pages.** Server list, per-server status, killfeed, leaderboard; `capabilities`; nav | Module-rust | The site renders the last thing the game said while the game is off | | 4 | **Identity** (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID | | 5 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe | -| 6 | **Teams provider.** Event-driven with a baseline on connect — Rust delivers membership transitions in real time, so no sweep | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server | +| 6 | **Teams provider** (R5). Rust's first-party clans; membership event-driven, leadership read off `LocalClan` at snapshot | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server | | 7 | **Events: one budget, one lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-rust + 2 | The leased value is observed changing in the running game and restored, per key | | 8 | **Events: the Kits reward action** (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree | -| 9 | **Optional mod integrations.** One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module | -| 10 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter answered either way | +| 9 | **Optional mod integrations**, the uMod **Clans** plugin first (R5). One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module, Teams included | +| 10 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied | +| 11 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter answered either way | ### Why the lease comes before the reward action From 839e5d05a819d829933fa902c29ee2c8776e9494 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 11:40:10 -0500 Subject: [PATCH 03/10] docs(modules): R6 names the base mod set, and reading Clans settles R5 the other way 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 122 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index c07e82c..a8db9f2 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,7 +1,7 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. Five decisions of record settled; one -question open (§3). +**Status:** approved in outline 2026-09-15, not started. Six decisions of record settled; two +questions open (§3). The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. This is the document that builds it. Where the two disagree, this one is later and wins — but the dry @@ -20,15 +20,21 @@ chapter by chapter. This document is a *schedule and a set of decisions*, not a Three new repositories, mirroring the three the platform already has for Ultima Online, plus the optional fourth part that lives inside the module: -| Part | New repo | Mirrors | What it is | +| 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][kit] ch. 5) | +| Website module | [`RunicGateway/Module-Rust`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) | `Module-uo` | Routes, schema fragment, prebuilt client chunk, nav | +| Sidecar | [`RunicGateway/Rust-Link`](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) | `link` | Owns the game connection and the durable copy | +| Oxide bridge plugin | [`RunicGateway/Rust-Plugins`](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins) | `servuo-plugins` | C# inside the game, dials out, never blocks | +| *(event capability)* | — | — | Declarations inside `Module-Rust` ([kit][kit] ch. 5) | -None exist yet. The module is built from [`integration-kit/template/`][kit], which CI holds against a -pinned core — currently `MODULE_API_VERSION` **1.10.0**. +All three were created empty on **2026-09-15**. The module is built from +[`integration-kit/template/`][kit], which CI holds against a pinned core — currently +`MODULE_API_VERSION` **1.10.0**. + +**The repository name is not the module id.** `Module-uo` ships a module whose `id` is `uo`; this one +ships `rust`. §2.1 of the contract requires `id` to equal the directory core loads it from, which is +`modules/rust/`, and it is the prefix of every table and every mount — so the capitalisation in the +repository name reaches nothing inside the bundle. Settled before this document and unchanged by it (dry run, org lead, 2026-08-19): @@ -157,8 +163,83 @@ The distinction matters more than the names suggest, so it is worth stating prec provider should be fed. Clans is the right choice and this is why. - **The uMod Clans plugin's API is not in our mirror.** [`HOOKS.md`](HOOKS.md) is the *game's* 477 hooks; a plugin's own API is its own documentation. It is reached through `[PluginReference]` and - is null when absent, making it an R3-shaped dependency that must refuse with a reason. It is also - not currently staged locally and would need pulling and mirroring. + 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 9'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 9 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](https://umod.org/plugins/rust-kits) | 4.4.9 | 2026-06-04 | `https://umod.org/plugins/Kits.cs` | +| [Clans](https://umod.org/plugins/clans) | 0.2.10 | 2026-05-06 | `https://umod.org/plugins/Clans.cs` | +| [PopupNotifications](https://umod.org/plugins/popup-notifications) | 0.2.1 | 2026-08-09 | `https://umod.org/plugins/PopupNotifications.cs` | + +Every one has a direct `.cs` download, so phase 0's install step is three `curl`s into +`oxide/plugins/` and no manual retrieval. **Pull them fresh rather than using the copies staged in +`Downloads`**, which are an older vintage. + +`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)` / `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` that phase 8 must handle, both found by reading it rather than by reasoning +about it.** + +**`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. Phase 8 has to choose: accept online-only and say so in the action's +description, or keep our own persisted pending-grant queue in the bridge and redeem it on next +connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store +with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save. +Decide it deliberately at phase 8 rather than discovering it from a complaint. + +`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 @@ -166,13 +247,22 @@ kicked and left — but **no promote or leader-changed event**. Core's provider transitions. That makes phase 6 *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. -## 3. Open question +## 3. Open questions -**Which other uMod plugins are in the required base set?** Kits is named. The rest of "a couple" is -not, and phase 0 cannot finish its install list without it. Everything beyond the base set is -phase 9's optional tier. +The base set question is closed by R6. Two smaller ones are open, neither blocking: -`D:\rust\oxide\plugins\` is currently **empty**, so whatever the set is, all of it is a fresh install. +**Clans is in the base set *and* the Team provider reads first-party clans.** Those are not in +conflict and this plan reads them as complementary: the plugin is installed because a community +wants alliances and clan chat, while core's Teams are fed from the first-party system that actually +publishes membership transitions (R5). Phase 9's adapter surfaces the plugin's extra features beside +the provider rather than under it. **Worth confirming** — it is an interpretation, not something that +was stated. + +**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether phase 8 accepts +online-only or builds a persisted pending-grant queue is a real decision with real cost, and it is +deliberately deferred to that phase rather than guessed now. + +`D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0. ## 4. The test rig From a4544ba5f2348135d9594bd55ff0d90da7b4ba35 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 11:45:04 -0500 Subject: [PATCH 04/10] docs(modules): audit the plan against the whole contract, not just the game-facing chapters 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. --- modules/rust/PLAN.md | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index a8db9f2..bfc74eb 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -353,4 +353,95 @@ is invalidated at once, and **"gone, and that is fine" is a success**, not a fai - **`start.bat`'s RCON password is `letmein` in plaintext with `rcon.web 1`.** Acceptable on a loopback dev rig, and it must never be the shape anything published copies. +## 7. Contract coverage audit + +Added 2026-09-15 after re-reading the whole kit rather than only chapters 3–5. **The first draft of +§5 was built from the game-facing chapters and under-planned the website module by a wide margin.** +Every element of the contract is listed below with where it lands; the unplanned ones are the point +of the table. + +### The server handshake — ten registrations plus two hooks (ch. 2) + +| Call | Planned? | Where | +|---|---|---| +| `registerRoutes` | yes | 3 | +| `registerTeamProvider` | yes | 6 | +| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 7–8 | +| `onBoot` / `onShutdown` | **implicit only** | make explicit in 1 | +| `registerExtension` | **no** | the dry run's own manifest declared `admin.users.detail` | +| `registerNotificationStreams` | **no** | — | +| `registerEventTriggers` | **no** | — | +| `registerAudiences` | **no** | — | +| `registerEngagementSeeds` | **no** | — | +| `registerAnnounceLeg` | **no** | — | +| `registerPostHook` | **no** | — | +| `registerSlashCommands` | **no** | — | + +**The template registers eight of these and the plan covered three.** Triggers, audiences and +engagement seeds are in `template/server/index.js` as a matched set — a trigger declares the payload +and its audience `ceiling`, an audience resolves people over module data, and seeds ship the bodies +and rules. None of the three was in §5. + +### The bundle's own parts + +| Part | Planned? | Note | +|---|---|---| +| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 3 | +| **`mounts` and prefix choice** | **no** | shares one namespace with core's, and the loader's probe cannot see core's root-mounted endpoints | +| `schema.sql` | yes | 2 | +| **`purge.sql`** | **no** | required whenever a schema ships | +| **`swagger-fragment.json` + generator + `check:swagger`** | **no** | fixed filename, merged verbatim, must be committed and checked current | +| **`vite.config.js` aliases / shims / `checkExternals`** | **no** | the kit calls this the highest-risk mechanical detail in the system | +| **`checkImports.js`** | **no** | the guard that makes a reach into core's tree fail the build | +| **`release.yml`, install manifest, `sha256`, host allowlist** | **no** | there was no packaging phase at all | + +### The client half + +| Part | Planned? | Note | +|---|---|---| +| `registry.registerRoutes` / `registerNav` | yes | 3 | +| **`declareModuleSlot`** | **no** | the kit: *"you will need it the moment your game has anything like a guild"* — so phase 6 | +| `registerFeatureProvider` | **no** | how a nav row becomes conditional | +| **UI kit discipline** | **no** | `PublicLayout` needs `shell`; `PageHeader` takes `eyebrow`/`title`/`lead`/`center` and drops unknown props silently | + +### Beyond the module + +| Item | Planned? | Note | +|---|---|---| +| Sidecar rpc correlation | implicit | needed by 7–8; state it in 1 | +| **Asset bridge (ch. 3 §2b)** | **no** | Rust's item icons and 2,590 skin ids are exactly the "content already on the game host" case | +| **`.profile` landing page** | **no** | three repos added is precisely when it is owed | +| **`runicgateway.com`** | **no** | a second game is a headline platform change | +| **Android app** | **no** | it feature-detects `capabilities` and must render a site whose module it has never heard of | +| `docs/` | yes | 11 | + +### What this does to the phase list + +The gaps are not evenly distributed: most land in the website module, which the original §5 treated +as three phases and which the kit treats as the bulk of the work. The phase list needs a packaging +phase, a notifications-and-engagement phase, and explicit bundle-mechanics work inside phase 1 — +plus decisions on the optional surfaces (slash commands, extension slots, the asset bridge) before +they can be scheduled. Those decisions are §8. + +## 8. Questions this plan cannot answer for itself + +Open, in rough order of how much they change the schedule. None is blocking phase 0. + +1. **Notifications and engagement** — does v1 ship triggers, audiences and engagement seeds, or is + that a later workstream? The template treats them as core furniture; skipping them means a Rust + site that cannot mail anybody about anything. +2. **Discord slash commands** — the platform has a bot and `registerSlashCommands` exists. Does the + Rust module claim any? +3. **Extension slots** — the dry run's manifest declared `admin.users.detail` (a user's Rust + identities inside core's admin user page). Keep it? +4. **The asset bridge** — item icons and skins over the bridge, or not in v1? It is a whole + protocol's worth of work and the site renders without it. +5. **Android** — does the app get Rust support in this workstream, or does it stay UO-only until the + module ships? +6. **Which mount prefixes**, given they share a namespace with core's own. +7. **Server scale** — how many Rust servers should the first release support in the UI? The dry run + says the module holds one client per server; the pages differ a lot between one and six. +8. **Wipe retention** — when a wipe truncates gameplay data, what survives? All-time rollups, or a + clean slate each month? + [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit From 5dcb8088adb736f282c6fc9b69d9bde1a64653b7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 12:01:44 -0500 Subject: [PATCH 05/10] docs(modules): R7-R14 close the audit, and the phase list roughly doubles 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. --- modules/rust/PLAN.md | 334 ++++++++++++++++++++++++++++++------------- 1 file changed, 231 insertions(+), 103 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index bfc74eb..8a7ddef 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,7 +1,8 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. Six decisions of record settled; two -questions open (§3). +**Status:** approved in outline 2026-09-15, not started. **Fourteen decisions of record**; two +questions deferred by choice (§3). Audited against the whole contract, not just the game-facing +chapters (§7). The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. This is the document that builds it. Where the two disagree, this one is later and wins — but the dry @@ -98,7 +99,7 @@ teardown. This is neither: it is *continuously reconciled state where the websit and its nearest relative in the contract is the Team provider **inverted** — instead of core asking the module what the game knows, the module tells the game what the site knows. The mechanism it borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on -connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 10, +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 @@ -148,7 +149,7 @@ same declaration under whatever name the variant gives it, and the refusal is th **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 9 rather than phase 6. +phase 17 rather than phase 9. The distinction matters more than the names suggest, so it is worth stating precisely: @@ -179,13 +180,13 @@ first-party clans answer all three. Feeding the Team provider from first-party c **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 9's adapter surfaces, and it sits beside the Team provider rather +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 9 wants roster data from the plugin, the honest path is an +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 @@ -218,7 +219,7 @@ including the three things the event work actually needs: 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` that phase 8 must handle, both found by reading it rather than by reasoning +**Two traps in `GiveKit` that phase 13 must handle, both found by reading it rather than by reasoning about it.** **`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The @@ -231,11 +232,11 @@ 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. Phase 8 has to choose: accept online-only and say so in the action's +that moment, silently. Phase 13 has to choose: accept online-only and say so in the action's description, or keep our own persisted pending-grant queue in the bridge and redeem it on next connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save. -Decide it deliberately at phase 8 rather than discovering it from a complaint. +Decide it deliberately at phase 13 rather than discovering it from a complaint. `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 @@ -244,23 +245,139 @@ 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 6 *partly* snapshot-driven where the dry run predicted it would be +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: + +- **`ceiling` is required, has no default, and is not a ladder.** A `staff` ceiling does **not** permit + `owner`, because "one person" for a cheat-detection event is *the player it was detected on*. Fewer + people is not less exposure. +- **`subjectKey` must name one of your declared variables** — it is what the cooldown keys on. Core + refuses the module at boot if it names nothing, which is the good failure; the bad one it prevents + is every subject sharing `undefined`. +- **A rule group is offered once, per group key.** A rule appended to an existing group reaches + **fresh installs only** — so a rule that must reach existing deployments takes a new group key. + +And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a +module that emitted "the server is still up" as news. + +### R8 — multi-server from the start + +**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs +under `/rust/servers/:id`. Every gameplay row carries a server id as well as a `wipe_id`, and the +module holds one sidecar client per configured server. + +This is the dry run's finding 2 taken at face value: *"one module, one game" is not the same as "one +module, one server"*. Retrofitting an `:id` segment through every route, table and page is the +expensive version, and a Rust community runs several servers by default. + +### R9 — the live map, with every layer toggleable + +**Decided 2026-09-15 (org lead).** The site offers a live map, and **each layer is an operator switch +set to public / players-only / admin-only** through the existing visibility framework +([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md)). + +**The map is two problems and they take different paths, which is the thing to get right before +building either:** + +- **The map image is static content on the game host** — `proceduralmap....map` + beside the world save, regenerated only on a wipe. That is exactly [kit][kit] ch. 3 §2b's case, and + it takes that shape: **request/reply, never events** (a sidecar that broadcast it would write + megabytes into its own store and fan them at every client), **one in flight with an explicit busy**, + **two stages — what exists, then what changed**, and a **derivation version** separate from the + protocol so improving how we read the file invalidates a cached image whose source hash did not + move. And **no import on boot**: a wipe is an event the operator knows about and the website does + not. +- **Everything moving on it is live state** down the ordinary read path — monuments, cargo ship, + patrol helicopter, airdrops, locked crates, and player positions. + +**The layer switches are a security boundary, not a preference.** Public player positions in Rust are +a competitive-advantage leak — anyone, including people who do not play on the server, could locate +players and infer base positions. The default posture is monuments and world events public, player +and base layers admin-only, and an operator opening one up is a deliberate act with the consequence +stated on the switch. + +**This is the only asset-bridge work in scope.** Item icons and the 2,590 workshop skin ids stay out +of v1; kill feeds and kit lists render as text. + +### R10 — the Android app is in this workstream, capability-driven, trailing by one phase + +**Decided 2026-09-15 (org lead).** Full Rust support in the app, not a degradation check. It decides +which screens to show from **`GET /api/v1/public/modules`** — each started module's `capabilities` +array — and each app leg lands **one phase after** the website surface it consumes is merged, so it +is always built against a real endpoint rather than a planned one. + +Two endpoints that are *not* this and are easy to confuse with it, both checked on 2026-09-15: +`/api/v1/public/status` returns site mode plus a version block — the first-run probe and +version-mismatch guard, not a feature manifest — and `/api/health` on the internal app is a liveness +probe returning `{status:'ok'}`. Neither can say which pages exist. + +§2.9's rule governs: **treat an unknown capability as absent, and never infer a URL from one.** + +### R11 — a small read-only set of Discord slash commands + +**Decided 2026-09-15 (org lead).** Questions answered from data the module already holds — server +status, wipe schedule, leaderboards, who is online. No write verbs, and the account link stays on the +two surfaces R1 names rather than acquiring a third. + +The trap to carry in from the Teams work: **ephemerality is fixed at the deferral**, so a command +that might refuse must defer ephemeral or its refusal goes public in the channel. And the registries +have no removal path, which is an argument for adding a command late rather than early. + +### R12 — per-wipe detail plus all-time rollups + +**Decided 2026-09-15 (org lead).** Every gameplay row carries `wipe_id`; leaderboards default to the +current wipe; a separate rollup accumulates per player across wipes so a returning player's history +survives the monthly reset. + +The truncation is a **runtime operation on a module route, never a schema one** — §2.6's leading-verb +allowlist forbids `DELETE` and `TRUNCATE` in a fragment precisely because the fragment replays at +every boot and would empty the table on each restart. + +### R13 — two extension slots: `admin.users.detail` and `site.footer.status` + +**Decided 2026-09-15 (org lead).** An operator looking at a user sees their linked Steam identity, +per-server stats and site-authored permission grants in core's own admin user page; the footer +carries a shard-status indicator on every page. + +One module per slot, so claiming them also reserves them. And the naming rule matters for anyone +reading this later: **a slot is named for a PLACE, never for a meaning** — `site.footer.status` is +"the status-ish spot in the footer", not core knowing what a game server is. + +### R14 — `/rust` on all three tiers + +**Decided 2026-09-15 (org lead).** `public`, `admin` and `player` all mount `/rust`, exactly as +`module-uo` mounts `/uo`. Sub-surfaces are path segments: `/rust/servers/:id`, `/rust/map`, +`/rust/clans`. + +Chosen deliberately because **prefixes share one namespace with core's own and the loader's collision +probe cannot see all of core's** — several core endpoints are mounted at the tier root rather than +under a prefix. A noun from our own domain that equals the module id cannot collide, where +`/servers` or `/map` very well might. + ## 3. Open questions -The base set question is closed by R6. Two smaller ones are open, neither blocking: +The fourteen decisions above close every question the contract audit raised. Two remain, both +deliberately deferred rather than unanswered: -**Clans is in the base set *and* the Team provider reads first-party clans.** Those are not in -conflict and this plan reads them as complementary: the plugin is installed because a community -wants alliances and clan chat, while core's Teams are fed from the first-party system that actually -publishes membership transitions (R5). Phase 9's adapter surfaces the plugin's extra features beside -the provider rather than under it. **Worth confirming** — it is an interpretation, not something that -was stated. +**Clans is in the base set *and* the Team provider reads first-party clans.** Not in conflict — the +plugin is installed because a community wants alliances and clan chat, while core's Teams are fed +from the first-party system that actually publishes membership transitions (R5). **Still an +interpretation rather than something stated.** -**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether phase 8 accepts -online-only or builds a persisted pending-grant queue is a real decision with real cost, and it is -deliberately deferred to that phase rather than guessed now. +**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether the reward phase +accepts online-only or builds a persisted pending-grant queue is a real decision with real cost, and +it belongs in that phase with the code in front of it. `D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0. @@ -290,28 +407,43 @@ re-checks it. ## 5. The phases -Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 6 is Teams. 7–8 -are events. 9 is the optional tier. 10 is how any of it reaches somebody who is not us. Each phase -ends with its findings written down, as every workstream here does. +**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. 12–13 are events. 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, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something | -| 1 | **Protocol 1 and three skeletons.** Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from `template/` | all 3 + docs | One hello line travels game → sidecar → module, and killing the sidecar does not stall the game | -| 2 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots kept distinct at the wire; `wipe_id` everywhere; every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation | -| 3 | **The first pages.** Server list, per-server status, killfeed, leaderboard; `capabilities`; nav | Module-rust | The site renders the last thing the game said while the game is off | -| 4 | **Identity** (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID | -| 5 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe | -| 6 | **Teams provider** (R5). Rust's first-party clans; membership event-driven, leadership read off `LocalClan` at snapshot | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server | -| 7 | **Events: one budget, one lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-rust + 2 | The leased value is observed changing in the running game and restored, per key | -| 8 | **Events: the Kits reward action** (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree | -| 9 | **Optional mod integrations**, the uMod **Clans** plugin first (R5). One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module, Teams included | -| 10 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied | -| 11 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter answered either way | +| 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`](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 | Module-Rust + docs | An operator turns on a rule, edits a body, and a wipe announcement reaches the right people 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: one budget, one verified lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-Rust + 2 | The leased value is observed changing in the running game and restored, per key | +| 13 | **Events: the Kits reward action** (R3, R6). Includes the offline-grant decision (§3) | all 3 | A retried step grants loot once, and the ledger and the world agree | +| 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.** The uMod **Clans** adapter first — alliances and clan chat, beside the provider rather than under it (R5) — then others, each detecting via `[PluginReference]` and degrading to absent | Rust-Plugins + docs | A server missing every optional mod still runs the module, Teams included | +| 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied | +| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way | ### Why the lease comes before the reward action -The stated priority is Kits rewards, and this plan still schedules a lease first. [kit][kit] ch. 5 is +The stated priority is Kits rewards, and this plan still schedules a lease first (12 before 13). [kit][kit] ch. 5 is explicit about it and the reasoning survives restating: a lease is *a value that already existed, changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action makes something that did not exist. The lease proves the whole command path — correlation, the @@ -321,7 +453,7 @@ failures. Reversible on request. -### What phase 8 must declare honestly +### What phase 13 must declare honestly **A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and have the thing undone, on every terminal path including an abort — and there is no honest way to @@ -355,93 +487,89 @@ is invalidated at once, and **"gone, and that is fine" is a success**, not a fai ## 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.** -Every element of the contract is listed below with where it lands; the unplanned ones are the point -of the table. +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 | Planned? | Where | +| Call | In the first draft | Phase | |---|---|---| -| `registerRoutes` | yes | 3 | -| `registerTeamProvider` | yes | 6 | -| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 7–8 | -| `onBoot` / `onShutdown` | **implicit only** | make explicit in 1 | -| `registerExtension` | **no** | the dry run's own manifest declared `admin.users.detail` | -| `registerNotificationStreams` | **no** | — | -| `registerEventTriggers` | **no** | — | -| `registerAudiences` | **no** | — | -| `registerEngagementSeeds` | **no** | — | -| `registerAnnounceLeg` | **no** | — | -| `registerPostHook` | **no** | — | -| `registerSlashCommands` | **no** | — | +| `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 | -**The template registers eight of these and the plan covered three.** Triggers, audiences and -engagement seeds are in `template/server/index.js` as a matched set — a trigger declares the payload -and its audience `ceiling`, an audience resolves people over module data, and seeds ship the bodies -and rules. None of the three was in §5. +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 | Planned? | Note | +| Part | In the first draft | Phase | |---|---|---| -| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 3 | -| **`mounts` and prefix choice** | **no** | shares one namespace with core's, and the loader's probe cannot see core's root-mounted endpoints | -| `schema.sql` | yes | 2 | -| **`purge.sql`** | **no** | required whenever a schema ships | -| **`swagger-fragment.json` + generator + `check:swagger`** | **no** | fixed filename, merged verbatim, must be committed and checked current | -| **`vite.config.js` aliases / shims / `checkExternals`** | **no** | the kit calls this the highest-risk mechanical detail in the system | -| **`checkImports.js`** | **no** | the guard that makes a reach into core's tree fail the build | -| **`release.yml`, install manifest, `sha256`, host allowlist** | **no** | there was no packaging phase at all | +| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 4 | +| `mounts` and prefix choice | **no** | 1 (R14) | +| `schema.sql` | yes | 1, 3 | +| `purge.sql` | **no** | 1 | +| `swagger-fragment.json` + generator + `check:swagger` | **no** | 1 | +| `vite.config.js` aliases / shims / `checkExternals` | **no** | 1 | +| `checkImports.js` | **no** | 1 | +| `release.yml`, install manifest, `sha256`, host allowlist | **no** | **2** — there was no packaging phase at all | + +Phase 1 carries most of these on purpose. [kit][kit] ch. 1's whole argument is to get every seam +working at once with almost nothing in them, so that afterwards you break exactly one at a time. ### The client half -| Part | Planned? | Note | +| Part | In the first draft | Phase | |---|---|---| -| `registry.registerRoutes` / `registerNav` | yes | 3 | -| **`declareModuleSlot`** | **no** | the kit: *"you will need it the moment your game has anything like a guild"* — so phase 6 | -| `registerFeatureProvider` | **no** | how a nav row becomes conditional | -| **UI kit discipline** | **no** | `PublicLayout` needs `shell`; `PageHeader` takes `eyebrow`/`title`/`lead`/`center` and drops unknown props silently | +| `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 | Planned? | Note | +| Item | In the first draft | Phase | |---|---|---| -| Sidecar rpc correlation | implicit | needed by 7–8; state it in 1 | -| **Asset bridge (ch. 3 §2b)** | **no** | Rust's item icons and 2,590 skin ids are exactly the "content already on the game host" case | -| **`.profile` landing page** | **no** | three repos added is precisely when it is owed | -| **`runicgateway.com`** | **no** | a second game is a headline platform change | -| **Android app** | **no** | it feature-detects `capabilities` and must render a site whose module it has never heard of | -| `docs/` | yes | 11 | +| 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 this does to the phase list +**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. -The gaps are not evenly distributed: most land in the website module, which the original §5 treated -as three phases and which the kit treats as the bulk of the work. The phase list needs a packaging -phase, a notifications-and-engagement phase, and explicit bundle-mechanics work inside phase 1 — -plus decisions on the optional surfaces (slash commands, extension slots, the asset bridge) before -they can be scheduled. Those decisions are §8. +## 8. Questions the audit raised — all answered -## 8. Questions this plan cannot answer for itself +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: -Open, in rough order of how much they change the schedule. None is blocking phase 0. +| 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 | -1. **Notifications and engagement** — does v1 ship triggers, audiences and engagement seeds, or is - that a later workstream? The template treats them as core furniture; skipping them means a Rust - site that cannot mail anybody about anything. -2. **Discord slash commands** — the platform has a bot and `registerSlashCommands` exists. Does the - Rust module claim any? -3. **Extension slots** — the dry run's manifest declared `admin.users.detail` (a user's Rust - identities inside core's admin user page). Keep it? -4. **The asset bridge** — item icons and skins over the bridge, or not in v1? It is a whole - protocol's worth of work and the site renders without it. -5. **Android** — does the app get Rust support in this workstream, or does it stay UO-only until the - module ships? -6. **Which mount prefixes**, given they share a namespace with core's own. -7. **Server scale** — how many Rust servers should the first release support in the UI? The dry run - says the module holds one client per server; the pages differ a lot between one and six. -8. **Wipe retention** — when a wipe truncates gameplay data, what survives? All-time rollups, or a - clean slate each month? +The two in §3 are deferred by choice rather than unanswered. [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit From 43bf73bae51092b481bc1cd3c04b16b50ceebd7f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 12:11:11 -0500 Subject: [PATCH 06/10] docs(modules): R15 the optional tier with BetterChat, R16 rewards grant the right to redeem 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 156 +++++++++++++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 34 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 8a7ddef..a63ee0b 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,8 +1,7 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. **Fourteen decisions of record**; two -questions deferred by choice (§3). Audited against the whole contract, not just the game-facing -chapters (§7). +**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](../rust-dryrun.md) designed this module on paper and deliberately did not build it. This is the document that builds it. Where the two disagree, this one is later and wins — but the dry @@ -219,8 +218,12 @@ including the three things the event work actually needs: 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` that phase 13 must handle, both found by reading it rather than by reasoning -about it.** +**Two traps in `GiveKit`, both found by reading it rather than by reasoning about it.** + +> **R16 moved these off the critical path.** The reward action no longer calls `GiveKit` — it grants +> the kit's `RequiredPermission` and the player redeems it themselves. Both traps are kept here +> because the second one is *why* R16 is the better design, and because anything that ever does call +> `GiveKit` directly — an admin "give this player a kit now" button, say — walks straight into them. **`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The first line is `if (!player) return null;`. Everywhere else in this ecosystem a null return means @@ -232,11 +235,14 @@ 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. Phase 13 has to choose: accept online-only and say so in the action's -description, or keep our own persisted pending-grant queue in the bridge and redeem it on next -connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store -with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save. -Decide it deliberately at phase 13 rather than discovering it from a complaint. +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 @@ -365,21 +371,82 @@ probe cannot see all of core's** — several core endpoints are mounted at the t 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](https://umod.org/plugins/better-chat)** (LaserHydra, 5.2.15, +MIT, Universal/Covalence, ~200k downloads) — *"manage chat groups, customize colors, and add +titles"*. The motivating case is **titles earned from leaderboards**: top of the wipe's kill board +gets a tag in chat. + +**Its integration point is a pull, not a push, and that is why it is a good first member.** +`API_RegisterThirdPartyTitle(Plugin plugin, Func titleGetter)` registers a callback, +and BetterChat invokes it per player when it renders a chat line. So a leaderboard title is a *pure +function of state we already hold* — nothing is written into BetterChat, nothing can go stale, and +there is no drift to reconcile. Contrast R2, which is a push and needs a whole reconcile story. + +**The one trap, and it is chapter 4's rule applied to somebody else's callback:** that getter runs +**synchronously on the chat path**. It must be a cheap in-memory lookup — never a socket call, never +a database query, never anything that can block. A title that costs a round trip is a chat message +that costs a round trip. + +Its other two API methods, `API_AddGroup(group)` and `API_SetGroupField(group, field, value)`, pair +naturally with R2: the site already authors permission groups, so a site-authored group can carry a +chat colour and tag. That is a push and would need the same drift posture R2 has; it is a phase-17 +decision, not a given. + +**The tier is open-ended by design.** Other integrations get added as they prove useful, and the bar +for each is the one this plan applies everywhere: it must fulfil the contract — declare honestly, +degrade to absent, and never make the module's own surfaces depend on something that may not be +installed. + +### R16 — the reward action grants the RIGHT to redeem, not the items + +**Decided 2026-09-15 (org lead).** An event reward does **not** call `GiveKit`. It grants the +**permission that gates a kit**, and the player redeems it themselves in game, whenever they next +log in. + +**Kits already has exactly this model built in**, which is what makes it cheap: every kit carries a +`RequiredPermission`, `GiveKit`'s own path checks it before handing anything over, and the in-game +kit menu renders a kit the player lacks the permission for as locked rather than hiding it. +`GetKitInfo` returns that permission under `["permission"]`, so the module can read which kits are +gated and which are open to everyone. + +This is a better design than the one it replaces, and it is worth being explicit about how much it +removes: + +- **The offline-grant problem disappears entirely.** `GiveKit` needed a connected `BasePlayer`, so an + event firing at two in the morning rewarded only whoever happened to be online. An entitlement + waits. **This closes the open question §3 carried** — no pending-grant queue, no second + at-most-once store, none of it. +- **It is the same machinery as R2, not a second mechanism.** A reward becomes a permission grant + authored by the site and mirrored into Oxide — the thing phase 7 already builds. One permission + authority, one drift story, one audit trail. +- **`reversible: 'ledger'` becomes honest**, where a direct grant could only ever be `'none'`. See + *"What phase 13 must declare honestly"* below. +- **The player gets agency.** They redeem when they want it, where they want it, with Kits' own + cooldown and use limits still applying — rather than having items appear in their inventory, + possibly while they are somewhere it is a liability. + +**One design note the option source has to carry.** A kit with an **empty** `RequiredPermission` is +open to everybody, so granting a permission for it rewards nobody with anything. The authoring form's +kit dropdown must surface which kits are permission-gated and refuse — or at minimum warn loudly — +on one that is not. That is a real refusal with a real reason, and exactly what R3's envelope is for. + ## 3. Open questions -The fourteen decisions above close every question the contract audit raised. Two remain, both -deliberately deferred rather than unanswered: +**None.** Both questions this section carried were closed on 2026-09-15. -**Clans is in the base set *and* the Team provider reads first-party clans.** Not in conflict — the -plugin is installed because a community wants alliances and clan chat, while core's Teams are fed -from the first-party system that actually publishes membership transitions (R5). **Still an -interpretation rather than something stated.** +*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.** `GiveKit` requires a connected `BasePlayer` (R6). Whether the reward phase -accepts online-only or builds a persisted pending-grant queue is a real decision with real cost, and -it belongs in that phase with the code in front of it. - -`D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0. +*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 @@ -433,11 +500,11 @@ Each phase ends with its findings written down, as every workstream here does. | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook | Module-Rust + docs | An operator turns on a rule, edits a body, and a wipe announcement reaches the right people 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: one budget, one verified lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-Rust + 2 | The leased value is observed changing in the running game and restored, per key | -| 13 | **Events: the Kits reward action** (R3, R6). Includes the offline-grant decision (§3) | all 3 | A retried step grants loot once, and the ledger and the world agree | +| 13 | **Events: the Kits reward action** (R3, R16). Grants the kit's `RequiredPermission`, not the items; `reversible: 'ledger'`; the 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.** The uMod **Clans** adapter first — alliances and clan chat, beside the provider rather than under it (R5) — then others, each detecting via `[PluginReference]` and degrading to absent | Rust-Plugins + docs | A server missing every optional mod still runs the module, Teams included | +| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included | | 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied | | 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way | @@ -455,19 +522,40 @@ Reversible on request. ### What phase 13 must declare honestly -**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and -have the thing undone, on every terminal path including an abort — and there is no honest way to -un-grant loot a player has already spent. The correct declaration is `reversible: 'none'`, and saying -so is the point: a capability that claims a reversal it cannot perform is the "capability that lies" -chapter 5 names, and neither core nor review can catch it. +> **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. -`cost()` must count the kits actually granted, derived from the params, every time. Core prices -`cost` before dispatch and never reconciles it against what came back — it cannot, it does not know -what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30 -into a cap of 360 with nothing anywhere going red. +**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. -And the monthly wipe is the case `revert`'s tolerance rule was written for: every ledgered resource -is invalidated at once, and **"gone, and that is fine" is a success**, not a failure. +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 From 601ee4e05f60c1b0ea2301597628489177bbac3d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 12:17:39 -0500 Subject: [PATCH 07/10] docs(modules): the event catalogue and the engagement catalogue 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 156 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index a63ee0b..7e6596b 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -479,8 +479,9 @@ honest reading is that the first list was a game-bridge plan with a website modu 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. 12–13 are events. 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 +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. @@ -497,10 +498,10 @@ Each phase ends with its findings written down, as every workstream here does. | 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 | Module-Rust + docs | An operator turns on a rule, edits a body, and a wipe announcement reaches the right people and nobody else | +| 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: one budget, one verified lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-Rust + 2 | The leased value is observed changing in the running game and restored, per key | -| 13 | **Events: the Kits reward action** (R3, R16). Grants the kit's `RequiredPermission`, not the items; `reversible: 'ledger'`; the 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 | +| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking | +| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate | 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 | @@ -658,6 +659,149 @@ recorded as R7–R14 in §2 rather than repeated here: | Extension slots? | `admin.users.detail` **and** `site.footer.status` | R13 | | Which mount prefixes? | `/rust` on all three tiers | R14 | -The two in §3 are deferred by choice rather than unanswered. +§3 is empty; both were closed on the same day. + +## 9. The event catalogue + +Added 2026-09-15. **Phases 12–13 described the event *mechanism* and never the *catalogue*** — one +budget and one lease as a proof of life, which is a skeleton rather than a product. This section is +what the module actually declares. + +[`EVENTS.md`](../../website/EVENTS.md) **§H is a Rust/Oxide compatibility section that already +sketched this**, and it should have been read before §5 was written. What follows takes its ids and +its reasoning as the starting point rather than inventing a parallel set. + +> **§H's thesis, and it is the one to design around:** *"The lease is the primitive that travels, not +> the spawn. Double gather rate for the weekend is the canonical Rust community event, and it is +> exactly lease-with-expiry. Spawning creatures at a landmark is UO-shaped; holding a value for four +> hours is every game."* It also rates Rust the **easier** case than UO, because Oxide's convars are +> live by default where ServUO's are mostly cached at boot. + +### Budgets — what core counts and bounds + +| Dimension | Counts | +|---|---| +| `rust.prefabs` | objects placed into the world by a run | +| `rust.zone.minutes` | zone/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.` | 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`](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. [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit From 1138fd36dd8c7f52439454551a1ae31898578d23 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 12:23:13 -0500 Subject: [PATCH 08/10] docs(modules): R17 makes ZoneManager a base plugin, and it settles two loose ends 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 83 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 7e6596b..4a833d2 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,7 +1,8 @@ # `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). +**Status:** approved in outline 2026-09-15, not started. **Seventeen decisions of record, no open +questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and +engagement catalogues are §9 and §10. The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. This is the document that builds it. Where the two disagree, this one is later and wins — but the dry @@ -188,20 +189,28 @@ storage, not using an API** — it breaks without warning on any upstream refact 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 +### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager -**Named 2026-09-15 (org lead).** All three are MIT, all by the same author, all fetched at plan time: +**Named 2026-09-15 (org lead), extended the same day by R17.** All four are MIT; three are k1lly0u's +and BetterChat's author differs only in the optional tier. All fetched at plan time: | Plugin | Version | Released | Source | |---|---|---|---| | [Kits](https://umod.org/plugins/rust-kits) | 4.4.9 | 2026-06-04 | `https://umod.org/plugins/Kits.cs` | | [Clans](https://umod.org/plugins/clans) | 0.2.10 | 2026-05-06 | `https://umod.org/plugins/Clans.cs` | | [PopupNotifications](https://umod.org/plugins/popup-notifications) | 0.2.1 | 2026-08-09 | `https://umod.org/plugins/PopupNotifications.cs` | +| [ZoneManager](https://umod.org/plugins/zone-manager) (R17) | 3.1.14 | 2026-09-02 | `https://umod.org/plugins/ZoneManager.cs` | -Every one has a direct `.cs` download, so phase 0's install step is three `curl`s into +Every one has a direct `.cs` download, so phase 0's install step is four `curl`s into `oxide/plugins/` and no manual retrieval. **Pull them fresh rather than using the copies staged in `Downloads`**, which are an older vintage. +**The four base plugins use three different conventions for their callable API**, which is worth +knowing before someone goes hunting for the wrong one: Kits uses `[HookMethod]`, BetterChat uses +`API_`-prefixed methods, and ZoneManager uses plain private methods resolved by name. All three are +reached the same way from our side — `[PluginReference]` plus `Call()` — but only the first is +greppable as a declared API. + `Clans` is listed by uMod as a **Universal** plugin rather than a Rust one — it is written against Covalence, and its API takes `IPlayer` rather than `BasePlayer`. That is the portable half of the uMod surface, and it is why the same plugin serves several games. @@ -371,6 +380,63 @@ probe cannot see all of core's** — several core endpoints are mounted at the t under a prefix. A noun from our own domain that equals the module id cannot collide, where `/servers` or `/map` very well might. +### R17 — ZoneManager joins the required base set, because events need to know where people are + +**Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u, +3.1.14 released 2026-09-02, MIT, Rust, ~218k downloads) is a fourth **required** plugin, not an +optional one. It is what makes an event able to answer *where a player is*. + +Reading its source changed two things this plan had been vague about, and promoted one action out of +the optional tier. + +**Its API is private methods reached through Oxide's reflection `Call()`** — no `[HookMethod]`, no +`API_` prefix, which is a third convention among the four base plugins and worth knowing before +someone goes looking for one that is not there. + +| Surface | What it gives an event | +|---|---| +| `IsPlayerInZone(zoneId, player)` → `bool` | the direct question | +| `GetPlayerZoneIDs(player)` → `string[]` | every zone a player is in | +| **`GetPlayerZoneIDsNoAlloc(player, List)`** | the allocation-free variant — **the one to use on any sweep** | +| `CreateOrUpdateZone(zoneId, args, position)` | make a zone | +| **`CreateOrUpdateTemporaryZone(..., Plugin owner)`** | make a zone *owned by our plugin* | +| **`EraseTemporaryZone(Plugin owner, zoneId)`** | remove one, **scoped to the owner** | +| `GetZoneIDs` / `GetZoneName` / `GetZoneLocation` / `CheckZoneID` | the catalogue, for an option source | + +And nine hooks raised: `OnEnterZone` / `OnExitZone`, `OnEntityEnterZone` / `OnEntityExitZone`, +`OnZoneInitialize` / `OnZoneUpdated` / `OnZoneDestroyed` / `OnZoneErased`, plus `CanSpawnInZone`, +which is a veto our bridge abstains from like every other. + +**Three things this settles.** + +**Participation stops being the hard part.** [`EVENTS.md`](../../website/EVENTS.md) §H rates +participation *"the hard part"* for UO and *"substantially easier"* for Rust because hooks carry +attacker and victim. ZoneManager makes it exact rather than merely easier: `OnEnterZone` and +`OnExitZone` are **presence transitions delivered as events**, so the participation ledger is fed +from what happened rather than reconstructed from a sweep. An action's reply carries its +`participants` envelope member, and this is where that member gets its content. + +**Advance conditions become expressible.** A phase that waits until *"ten players are at the +monument"* is a real gate rather than a wish — it reads zone membership. Without ZoneManager the +same condition is distance arithmetic against a point, recomputed on a timer, which is both more +expensive and less accurate. + +**`rust.zone.open` moves from the optional tier into the base catalogue, and it can be +`reversible: 'ledger'` honestly.** This is the nicer half: `CreateOrUpdateTemporaryZone` takes a +**`Plugin owner`** and `EraseTemporaryZone` is **scoped to that owner**, so ZoneManager already has a +first-class notion of a zone belonging to the plugin that made it. That is most of the persisted +ownership registry [kit][kit] ch. 4 demands — we still keep our own map from core's resource +reference to the zone id, but we are not inventing ownership, we are borrowing a concept the plugin +already has. And erasing a zone that is gone is a success, which is what `revert` needs. + +**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and +`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of +them. The emit path already enqueues and returns, so the game cannot stall — but **the bridge should +subscribe selectively rather than forwarding every transition in every zone**. A zone no event cares +about should cost nothing on the wire. Decide the filter with the hooks in front of you at phase 12, +and measure it, because a sweep over `GetPlayerZoneIDsNoAlloc` is cheap and a flood of wire traffic +is not. + ### R15 — an optional-integration tier, with BetterChat as the first member **Decided 2026-09-15 (org lead).** Beyond the required base set (R6) the module carries a tier of @@ -488,7 +554,7 @@ 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 | +| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it | | 1 | **Protocol 1, three skeletons, and every bundle seam at once.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton | | 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` | | 3 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history | @@ -682,7 +748,7 @@ its reasoning as the starting point rather than inventing a parallel set. | Dimension | Counts | |---|---| | `rust.prefabs` | objects placed into the world by a run | -| `rust.zone.minutes` | zone/dome time held | +| `rust.zone.minutes` | zone time held — real since R17 | | `rust.grants` | entitlements granted (R16) | | `rust.announcements` | in-game broadcasts | @@ -699,6 +765,7 @@ operator setting a cap of 30 prefabs is setting it per server. Say so on the fie | `rust.options.permissions` | registered permissions | | `rust.options.prefabs` | a plugin-declared constructible allowlist — the analogue of UO's spawn atlas | | `rust.options.monuments` | monument names, shared with the map work (R9) | +| `rust.options.zones` | ZoneManager `GetZoneIDs` / `GetZoneName` (R17) | Every one resolves from live data and returns `[]` on failure rather than defending with a hardcoded list that will be wrong. A source that refuses degrades its field to free text with a warning and @@ -738,7 +805,7 @@ cleanly, reads back cleanly, and does nothing at all, and neither core nor revie | `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 | +| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner` and `EraseTemporaryZone` is scoped to it, so the undo is real | **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 — From 82f2b0d18df770b12bcdfd114acfb53e6392e956 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 12:29:53 -0500 Subject: [PATCH 09/10] docs(modules): R18 edits plugin configuration from the site, with a generated form and an auto-reload 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/.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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 89 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 4a833d2..f33f02b 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1,6 +1,6 @@ # `module-rust` — the plan -**Status:** approved in outline 2026-09-15, not started. **Seventeen decisions of record, no open +**Status:** approved in outline 2026-09-15, not started. **Eighteen decisions of record, no open questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and engagement catalogues are §9 and §10. @@ -380,6 +380,88 @@ probe cannot see all of core's** — several core endpoints are mounted at the t under a prefix. A noun from our own domain that equals the module id cannot collide, where `/servers` or `/map` very well might. +### R18 — plugin configuration is editable from the site, with a generated form and an auto-reload + +**Decided 2026-09-15 (org lead).** An admin edits any loaded plugin's configuration from the website +and it reloads automatically. Two tiers: + +- **Base:** the site reads `oxide/config/.json` and **generates a form from the values + themselves** — a boolean becomes a toggle, a number a numeric field, a string a text box, an array + a list, a nested object a group. It is derived at read time, so **it works for whatever plugins + happen to be installed**, including ones added or removed after we shipped. +- **Advanced:** raw JSON editing for anything the generated form cannot express. + +This is the same posture as R2 — the site as the authority over the game host — but a different +*shape*. R2 is continuously reconciled state pushed on every connect; this is **request/reply, +on demand** ([kit][kit] ch. 3 §2a). Do not build it on the permission mirror. + +**The mechanics Oxide gives us, all verified 2026-09-15:** configs live at +`oxide/config/.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. + +#### The trap that would silently corrupt every float + +**JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.** +`JSON.parse('{"Rate":1.0}')` yields the number `1`, and `JSON.stringify` writes it back as `1` — so +a naive read-modify-write of a config file **silently rewrites every whole-numbered float as an +integer**, on fields nobody touched. Newtonsoft may coerce it, or may throw, and a throw at load +means the plugin does not come back. + +So: **never parse the whole document, mutate, and re-serialize.** Edit the file *textually* — a +surgical replacement of the edited key's value — or use a parser that preserves number literals. The +fields at risk are exactly the ones a Rust server tunes: gather rates, multipliers, scales. + +#### Five more limits of inferring a schema from values + +Worth stating because the feature's whole promise is that it works without knowing the plugin: + +- **Empty arrays and `null` carry no type.** Nothing can be inferred; render them advanced-only. +- **Enum-like strings are indistinguishable from free text.** There is no allowed-value set to read, + so a string field is a text box and cannot be validated. +- **There are no descriptions, no minimums and no maximums.** The key name is the entire label — + which is survivable because Oxide convention favours readable keys (BetterChat really does ship + `"Maximal Titles"` and `"Reverse Title Order"`). +- **Nested objects and arrays of objects need recursion**, a depth limit, and a fall-back to raw JSON + past it. +- **The file after a reload may not be what we wrote.** Oxide merges missing defaults and saves, so + re-read after reloading rather than assuming our write is the current state. + +#### Safety, and why it needs more than the usual + +**A bad config does not fail the write — it fails the next load, and the plugin stays down.** Since +R6 and R17 make four plugins *required*, a broken ZoneManager config takes event participation with +it. So the write path is: + +1. Read with a **version** (hash or mtime) and require it back on write — an operator editing on + disk at the same time gets a conflict rather than a silent overwrite. +2. Validate the edited document parses. +3. **Back up the current file**, write, reload. +4. **Watch for `OnPluginLoaded` within a window.** If it does not arrive, **restore the backup and + reload again, automatically**, and report the failure with whatever Oxide logged. + +That rollback is the feature's real content. Without it this is a web form that can take the shard's +plugins down one typo at a time. + +**Two more obligations.** + +**Redact secrets.** Plugin configs routinely hold API keys and Discord webhooks. A config reader +hands those to anyone who can open the page. Mask values whose key matches the usual shapes — key, +token, secret, password, webhook — and treat them **write-only**, exactly as the platform already +treats the uo-link token. This is a real leak vector and the generated-form approach walks straight +into it. + +**Gate it on its own site permission and audit every write** — who changed which key, from what to +what, and whether the reload succeeded. It is an admin writing to the game host's filesystem, which +is the most powerful thing the site can do to a server. + +#### One distinction to keep + +**Editing a plugin's config file is not a lease.** A lease (§9) borrows a *convar* for a while and +the game restores it on a deadline. This writes a *file* and is permanent until someone changes it +back. They look similar from a web form and they are not the same mechanism — an event should never +reach for this one. + ### R17 — ZoneManager joins the required base set, because events need to know where people are **Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u, @@ -544,8 +626,8 @@ re-checks it. 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 +0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7b are +the permissions and remote-administration product. 9 is Teams. 10 is the notifications set, whose catalogue is **§10**. 12–13 are events, whose catalogue is **§9**. 14 is the map. 18 is how any of it reaches somebody who is not us. The Android legs (5, 8, 11, 15) each trail the website surface they consume by one phase, per R10. @@ -562,6 +644,7 @@ Each phase ends with its findings written down, as every workstream here does. | 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | | 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe | +| 7b | **Mod configuration from the site** (R18). Generated form from the live config values, raw-JSON advanced tier, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** on a failed load, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why | | 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 | From 6392f39512b5d8c44773fbdb03a62df57dabc5ca Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 12:34:38 -0500 Subject: [PATCH 10/10] docs(modules): R18 discovery is a recursive walk, and it stops at oxide/config Configuration is not one flat oxide/config/.json per plugin. Plugins nest - oxide/config//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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index f33f02b..d20b79f 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -385,7 +385,7 @@ under a prefix. A noun from our own domain that equals the module id cannot coll **Decided 2026-09-15 (org lead).** An admin edits any loaded plugin's configuration from the website and it reloads automatically. Two tiers: -- **Base:** the site reads `oxide/config/.json` and **generates a form from the values +- **Base:** the site walks `oxide/config/` **recursively** and **generates a form from the values themselves** — a boolean becomes a toggle, a number a numeric field, a string a text box, an array a list, a nested object a group. It is derived at read time, so **it works for whatever plugins happen to be installed**, including ones added or removed after we shipped. @@ -400,6 +400,43 @@ on demand** ([kit][kit] ch. 3 §2a). Do not build it on the permission mirror. `OnPluginUnloaded` are real hooks** in the Server category, so *whether a reload actually succeeded is observable* rather than assumed. +#### Discovery is a recursive walk, and it stops at `oxide/config/` + +**Amended 2026-09-15 (org lead).** Configuration is **not** one flat `oxide/config/.json` per +plugin. Plugins nest — `oxide/config//whatever.json`, and deeper — and one plugin may own +several files. So discovery is a **recursive walk** of the config tree, and the UI groups by plugin +rather than assuming one file each. + +Four things follow, and the first is a boundary rather than a detail. + +**`oxide/data/` is not the settings surface, and must not be walked into.** `DataFileSystem` writes +to `oxide/data/`, and that is **live state, not configuration**. The base set makes the point by +itself: Kits keeps `Kits/kits_data.json` and `Kits/player_data.json`, ZoneManager keeps +`ZoneManager/zone_data.json`, Clans keeps `clan_data.json` (and a legacy `clans_data.json` beside it, +which is also a reminder that these names are not stable). Editing those from a web form edits +**players' kit cooldowns and the live zone definitions**, a running plugin overwrites the change on +its next save, and `oxide.reload` does not make most plugins safely re-read them. It is a different +problem with a different answer and it is deliberately out of scope. If a plugin's *settings* genuinely +live under `data/`, that is a per-plugin exception someone opts into knowingly, never something the +walk discovers on its own. + +**The reload target cannot be inferred from the path.** `oxide/config/Foo/bar.json` may belong to +plugin `Foo`, or to something else entirely — the folder name is convention, not contract. So the +reload target is an **explicit field with the folder name as its default guess**, confirmable by the +admin. Infer it silently and the failure is the nastiest kind available here: we reload the wrong +plugin, observe `OnPluginLoaded` for *it*, and report success while the plugin that was actually +edited never re-read anything. + +**A relative path from a web form is now a path-traversal surface.** Canonicalise the resolved path, +assert it is genuinely under the config root, reject absolute paths, and reject symlinks that resolve +outside. Before this amendment the feature addressed files by plugin name; now it addresses them by +path, and that is exactly the change that introduces the bug class. + +**Bound the walk and the file.** A depth limit, a file-count limit and a per-file size cap — a +pathological tree must not be enumerated and a multi-megabyte JSON must not be loaded into a form. +And because one plugin can own several files, **the backup and rollback operate on the whole set a +save touches**, not one file at a time. + #### The trap that would silently corrupt every float **JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.** @@ -644,7 +681,7 @@ Each phase ends with its findings written down, as every workstream here does. | 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | | 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe | -| 7b | **Mod configuration from the site** (R18). Generated form from the live config values, raw-JSON advanced tier, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** on a failed load, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why | +| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `oxide/config/` (never `oxide/data/`), generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | | 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app | | 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly | | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |