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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 12:11:11 -05:00
parent 5dcb8088ad
commit 43bf73bae5

View File

@@ -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<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
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