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

Merged
whitlocktech merged 10 commits from docs/rust-module-plan into main 2026-09-15 17:41:17 +00:00
Showing only changes of commit 43bf73bae5 - Show all commits

View File

@@ -1,8 +1,7 @@
# `module-rust` — the plan # `module-rust` — the plan
**Status:** approved in outline 2026-09-15, not started. **Fourteen decisions of record**; two **Status:** approved in outline 2026-09-15, not started. **Sixteen decisions of record, no open
questions deferred by choice (§3). Audited against the whole contract, not just the game-facing questions.** Audited against the whole contract, not just the game-facing chapters (§7).
chapters (§7).
The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. 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 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 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. 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 **Two traps in `GiveKit`, both found by reading it rather than by reasoning about it.**
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 **`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 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 **`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 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 that moment, silently. The choice this forced was: 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 description, or keep a persisted pending-grant queue in the bridge and redeem it on next connect —
connect. **The queue is the honest answer and it is not free**it is a second at-most-once store a second at-most-once store with its own idempotency, which is not free.
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. **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, `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 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 under a prefix. A noun from our own domain that equals the module id cannot collide, where
`/servers` or `/map` very well might. `/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<IPlayer, string> titleGetter)` registers a callback,
and BetterChat invokes it per player when it renders a chat line. So a leaderboard title is a *pure
function of state we already hold* — nothing is written into BetterChat, nothing can go stale, and
there is no drift to reconcile. Contrast R2, which is a push and needs a whole reconcile story.
**The one trap, and it is chapter 4's rule applied to somebody else's callback:** that getter runs
**synchronously on the chat path**. It must be a cheap in-memory lookup — never a socket call, never
a database query, never anything that can block. A title that costs a round trip is a chat message
that costs a round trip.
Its other two API methods, `API_AddGroup(group)` and `API_SetGroupField(group, field, value)`, pair
naturally with R2: the site already authors permission groups, so a site-authored group can carry a
chat colour and tag. That is a push and would need the same drift posture R2 has; it is a phase-17
decision, not a given.
**The tier is open-ended by design.** Other integrations get added as they prove useful, and the bar
for each is the one this plan applies everywhere: it must fulfil the contract — declare honestly,
degrade to absent, and never make the module's own surfaces depend on something that may not be
installed.
### R16 — the reward action grants the RIGHT to redeem, not the items
**Decided 2026-09-15 (org lead).** An event reward does **not** call `GiveKit`. It grants the
**permission that gates a kit**, and the player redeems it themselves in game, whenever they next
log in.
**Kits already has exactly this model built in**, which is what makes it cheap: every kit carries a
`RequiredPermission`, `GiveKit`'s own path checks it before handing anything over, and the in-game
kit menu renders a kit the player lacks the permission for as locked rather than hiding it.
`GetKitInfo` returns that permission under `["permission"]`, so the module can read which kits are
gated and which are open to everyone.
This is a better design than the one it replaces, and it is worth being explicit about how much it
removes:
- **The offline-grant problem disappears entirely.** `GiveKit` needed a connected `BasePlayer`, so an
event firing at two in the morning rewarded only whoever happened to be online. An entitlement
waits. **This closes the open question §3 carried** — no pending-grant queue, no second
at-most-once store, none of it.
- **It is the same machinery as R2, not a second mechanism.** A reward becomes a permission grant
authored by the site and mirrored into Oxide — the thing phase 7 already builds. One permission
authority, one drift story, one audit trail.
- **`reversible: 'ledger'` becomes honest**, where a direct grant could only ever be `'none'`. See
*"What phase 13 must declare honestly"* below.
- **The player gets agency.** They redeem when they want it, where they want it, with Kits' own
cooldown and use limits still applying — rather than having items appear in their inventory,
possibly while they are somewhere it is a liability.
**One design note the option source has to carry.** A kit with an **empty** `RequiredPermission` is
open to everybody, so granting a permission for it rewards nobody with anything. The authoring form's
kit dropdown must surface which kits are permission-gated and refuse — or at minimum warn loudly —
on one that is not. That is a real refusal with a real reason, and exactly what R3's envelope is for.
## 3. Open questions ## 3. Open questions
The fourteen decisions above close every question the contract audit raised. Two remain, both **None.** Both questions this section carried were closed on 2026-09-15.
deliberately deferred rather than unanswered:
**Clans is in the base set *and* the Team provider reads first-party clans.** Not in conflict — the *Clans in the base set while the Team provider reads first-party* was confirmed as the intended
plugin is installed because a community wants alliances and clan chat, while core's Teams are fed reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
from the first-party system that actually publishes membership transitions (R5). **Still an provider is fed from the first-party system that actually publishes membership transitions (R5).
interpretation rather than something stated.**
**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether the reward phase *Offline reward grants* was dissolved rather than answered. **R16 changed the noun**: the reward
accepts online-only or builds a persisted pending-grant queue is a real decision with real cost, and action grants an entitlement instead of items, and an entitlement does not need the player to be
it belongs in that phase with the code in front of it. online. The persisted pending-grant queue that question was weighing is not needed at all.
`D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0.
## 4. The test rig ## 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 ### What phase 13 must declare honestly
**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and > **Rewritten 2026-09-15 by R16.** This section previously argued that a kit reward could only be
have the thing undone, on every terminal path including an abort — and there is no honest way to > `reversible: 'none'`, because there is no honest way to un-grant loot a player has already spent.
un-grant loot a player has already spent. The correct declaration is `reversible: 'none'`, and saying > That was correct *about a direct grant* and R16 stopped doing direct grants. The reasoning is kept
so is the point: a capability that claims a reversal it cannot perform is the "capability that lies" > below in its corrected form because the shape of the mistake is the reusable part: **the action was
chapter 5 names, and neither core nor review can catch it. > 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 **The reward action grants an entitlement, and an entitlement is reversible.** `revert` revokes the
`cost` before dispatch and never reconciles it against what came back — it cannot, it does not know permission, removing one that is not there is a success, and it is idempotent by construction — so
what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30 **`reversible: 'ledger'` is the honest declaration**, and core's ledger sweep does real work on every
into a cap of 360 with nothing anywhere going red. terminal path.
And the monthly wipe is the case `revert`'s tolerance rule was written for: every ledgered resource One consequence to state rather than discover: **a player who redeemed before the revert keeps the
is invalidated at once, and **"gone, and that is fine" is a success**, not a failure. 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 ## 6. Risks worth naming now