Files
docs/modules/rust/PLAN.md
wtclaude 0de9d7f90d docs(rust): phase 13 plan of record — split 13a/13b, D80–D95 (PLAN.md §28)
Phase 13 splits into 13a (the world verbs, the ownership registry and the
boot/wipe reconcile watch) and 13b (the participation tally, the kit
entitlement and announce). Records the org lead's sixteen decisions and the
rig facts they rest on: monument names repeat, crates keep their net id
across a restart while NPCs do not, and ZoneManager erases a plugin's
temporary zones when that plugin unloads. Also links the three core
findings phase 12 raised, now filed as website#205-#207.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 00:36:33 -05:00

4585 lines
329 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `module-rust` — the plan
**Status:** phases 0 and 1 done, 2026-09-15. **Twenty-two 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; §11 is a second pass over `MODULE_API.md`
itself. **R19–R22 (2026-09-15) added a second modding framework, a Pterodactyl egg, moved the rigs off
the workstation, and put the sidecar's configuration in the egg** — see [`CARBON.md`](CARBON.md) for
the framework reference and §14 for the rig as built.
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 | Repo | Mirrors | What it is |
|---|---|---|---|
| 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) |
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):
- **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).
**Phase 0 exercised all three against the real store and they hold — but it found four rules the
push path has to obey (§12.2).** The one that would have cost the most:
**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It
returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is
registered by the plugin that declares it, so **every grant naming an unloaded, renamed or
uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is
"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent
partial. The push must check `PermissionExists` (or register the name itself) and report the
difference as drift rather than assuming a write landed.
**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and
4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a
teardown. This is neither: it is *continuously reconciled state where the website is authoritative*,
and its nearest relative in the contract is the Team provider **inverted** — instead of core asking
the module what the game knows, the module tells the game what the site knows. The mechanism it
borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on
connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 19,
after it has been built once.
### R3 — the Kits reward action registers always and refuses with a reason
**Decided 2026-09-15 (org lead).** Kits is required for event rewards, but "required" means the
action always exists and fails honestly where it cannot work — `{ ok: false, retry: false, error:
'Kits plugin not installed' }` — rather than vanishing from the authoring form or refusing to boot
the module.
Two details from [kit][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
`"<action id> 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.
### 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 17 rather than phase 9.
The distinction matters more than the names suggest, so it is worth stating precisely:
- **Rust's first-party clans** are seven hooks in [`HOOKS.md`](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.
**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`.
> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks —
> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and
> concluded that the plugin cannot answer core's provider questions at all. **That was a grep
> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to
> a search for `CallHook("OnClan…` because the name is a `const` at the call site:
>
> ```csharp
> const string HOOK_NAME = "OnClanMemberJoined";
> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList);
> ```
>
> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`,
> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and
> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer
> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the
> exact transitions first-party lacks.
**The decision does not change, but its reason does.** First-party remains the Team provider's source
because it is the system the *game* maintains and every server has it; the plugin is an optional
install that not every shard will run, and feeding a provider from something optional makes Teams
conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that
it should not be the one asked. Feeding the Team provider from first-party clans is still
**permanent, not a first step**.
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
roster data. That is what phase 17's adapter surfaces, and it sits beside the Team provider rather
than under it.
One route is deliberately not taken: the plugin persists to its own files under `oxide/data/`, and a
determined integration could read those directly. **That is reaching into another plugin's private
storage, not using an API** — it breaks without warning on any upstream refactor and is not a
contract anybody owes us. If phase 17 wants roster data from the plugin, the honest path is an
upstream pull request adding a read method, not a file reader.
### R6 — the required base set: Kits, Clans, PopupNotifications and ZoneManager
> **Amended 2026-09-23 by D47 (§24):** the uMod **Clans** plugin is no longer required. It moved to
> phase 17's optional tier, because it shares no data with the game's own clans, which are what
> Teams are built from. The required set is Kits, PopupNotifications and ZoneManager.
**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 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.
**Kits is the opposite of Clans and is a genuinely good dependency.** Twenty-three `[HookMethod]`s,
including the three things the event work actually needs:
- `GiveKit(BasePlayer player, string name)` — the reward action's call;
- `GetKitNames(List<string>)` / `GetAllKits()` — **the option source** for the authoring form, so an
operator picks a kit from the live server instead of typing an identifier from memory;
- `GetKitInfo` / `KitDescription` / `KitImage` / `KitMax` / `KitCooldown` /
`GetPlayerKitUses` / `GetPlayerKitCooldown` — form metadata and per-player eligibility.
It also **raises `OnKitRedeemed(BasePlayer player, string kitName)`**, which the bridge can listen on
to report a redemption as an ordinary event, whoever triggered it.
**Two traps in `GiveKit`, both found by reading it rather than by reasoning about it.**
> **R16 moved these off the critical path.** The reward action no longer calls `GiveKit` — it grants
> the kit'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
*abstain*, so the reflex — treat null as fine — reports a reward as delivered when there was no
player to deliver it to. The success value is the literal `true`; a refusal is a **message string**,
which drops straight into chapter 5's `error` field. So the mapping is
`result is bool ok && ok` for success, a string for a refusal reason, and **`null` is a failure, not
a success.**
**`GiveKit` takes a `BasePlayer`, so the player must be connected.** There is no offline grant in
this API. An event that rewards participants at two in the morning rewards only whoever is online at
that moment, silently. The choice this forced was: accept online-only and say so in the action's
description, or keep a persisted pending-grant queue in the bridge and redeem it on next connect —
a second at-most-once store with its own idempotency, which is not free.
**R16 took a third option and it is the right one: stop granting items.** Grant the entitlement
instead. An entitlement waits without a queue, because waiting is what an entitlement does. The
problem was not hard to solve — it was the wrong problem, produced by an action declared around the
wrong noun.
`PopupNotifications` is small and does exactly one thing: `CreatePopupNotification(string message,
BasePlayer player = null, float duration = 0f)`, where a null player makes it global. That is the
server-side notification surface, and it needs no more than that.
**The one gap to design around:** the seven first-party hooks carry created, disbanded, added,
kicked and left — but **no promote or leader-changed event**. Core's provider requires
`getTeamLeaders`, so leadership is read off `LocalClan` at snapshot time rather than tracked from
transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
fully event-driven — a small correction to that document, recorded here rather than silently.
Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the
**Clans plugin closes it** — `OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does
not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven
leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design
phase 9's snapshot so phase 17 can sharpen it rather than replace it.
### 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.
> **Amended 2026-09-23 by D62 (§25.0).** The announce leg moves to phase 13 beside `rust.announce`,
> which needs the same in-game chat verb; the post hook is dropped until something in game can hold a
> post; PopupNotifications moves to phase 17. The other four registrations ship in phase 10.
### 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.<size>.<seed>.<save>.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.
### 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 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.
- **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/<PluginName>.json`; `oxide.reload <name>` rereads one; and **`OnPluginLoaded` /
`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/<Plugin>.json` per
plugin. Plugins nest — `oxide/config/<Mod>/whatever.json`, and deeper — and one plugin may own
several files. So discovery is a **recursive walk** 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.**
`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,
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<string>)`** | 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; owner-scoped **only against another plugin's zone**, not against an unowned one (§12.4) |
| `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. And erasing a zone that is gone is
a success, which is what `revert` needs.
> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is
> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all
> for a zone owned by **nobody**.
>
> ```csharp
> // Only compare zone owner if the owner param is provided so users can remove temporary zones
> // without needing to unload the plugin that created them
> if (owner && zoneOwner && owner != zoneOwner)
> return false;
> ```
>
> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So
> `EraseTemporaryZone(us, "<operator's zone>")` **deletes it and returns `true`**, indistinguishable
> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was
> erased by an `EraseTemporaryZone` call from an unrelated plugin.
>
> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We
> still keep our own map from core's resource reference to the zone id, and that map is now
> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not
> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not
> evidence the zone was ours.
**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
**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.
### R19 — the plugin is framework-agnostic: Oxide **and** Carbon, from now rather than later
**Decided 2026-09-15 (org lead).** Modded Rust runs on two frameworks, not one, and `module-rust`
supports both from the phase it first reads anything — not as a port after phase 18. The bridge
plugin stays **one `.cs` file in the `Oxide.Plugins` namespace deriving from `RustPlugin`**, which is
also Carbon's own documented first example, with `#if CARBON` used only where the APIs genuinely
differ.
**This is affordable because the divergence is concentrated, not spread.** Carbon is not a fork of
Oxide; it is a separate loader shipping an Oxide compatibility layer, and at the level a plugin sees
the two are the same API. [`CARBON.md`](CARBON.md) is the reference — where it came from, what was
read, and the honest note that **none of it has yet run on a live Carbon server.**
Three existing decisions take an amendment, and no decision is reversed:
- **R18 — paths come from the framework, never from a literal.** Carbon's config directory is
`carbon/configs` (plural) and its data directory `carbon/data`, *and every one of Carbon's
directories is relocatable from the command line* (`-carbon.configdir`, `-carbon.datadir`,
`-carbon.rootdir`, and nine more). So the recursive walk is rooted at
`Interface.Oxide.ConfigDirectory` and the directory it refuses to walk is
`Interface.Oxide.DataDirectory`. Carbon reimplements both accessors; a hardcoded `oxide/config/`
is wrong on Carbon *and* on an Oxide server whose operator moved things. The reasoning behind R18
is untouched — only how the two roots are obtained.
- **R2 — the store was never readable and now it is unreadable by construction.** Oxide persists
permissions as JSON (`oxide/data/oxide.users.data`); Carbon persists them as **Protobuf or
SQLite**, switchable at run time. R2 always planned to read the API, so nothing changes — but the
file-reading shortcut is now permanently closed, which is worth saying once. §12.2's
`PermissionExists` pre-check also survives intact: Carbon's `GrantUserPermission` returns `bool`
where Oxide's returns `void`, so **the framework that would have told us whether the write landed
is the one we cannot portably listen to.**
- **R4 — `doctor` asks *which* framework, not *whether Oxide*.** The payload drops into
`PluginDirectory` either way. One thing gets weaker: **Carbon's releases are rolling tags**
(`production_build`, `edge_build`), not an incrementing build number, so "current enough" is a
claim `doctor` can make about Oxide and can only approximate about Carbon.
**What this decision explicitly refuses.** Carbon publishes 30 hooks Oxide does not, including an
`OnCarbon*` family mirroring its admin module's every moderation action — a tempting staff-audit
feed, and precisely the thing that would quietly make Carbon required. **No Carbon-only hook and no
Carbon-only convar enters a catalogue** unless it has an Oxide answer first, or is advertised
conditionally on the connected server's framework as a deliberate decision. Likewise Carbon's native
`Carbon.Plugins` / `CarbonPlugin` shape is not used: it is the single choice that would make the
source Carbon-only.
**The one thing to hold loosely.** Thirteen hook names our uMod mirror carries are absent from
Carbon's published catalogue ([`CARBON.md`](CARBON.md) §6). At least two look like renames rather
than holes, and none is in a phase today. The protection is the one §6 already requires for a
different reason — *the plugin logs which of its expected hooks have fired at least once* — which
answers this on either framework without trusting either catalogue.
### R20 — a Pterodactyl egg is a Rust-Link deliverable, with the sidecar inside the game container
**Decided 2026-09-15 (org lead).** Most Rust servers are rented, and most rented Rust servers run on
a Pterodactyl panel. So alongside the installer (R4) and the hand install, **a published Pterodactyl
egg is the third supported way the shard side reaches an operator** — and it has to work *in the same
manner* as the other two, not as a degraded variant.
The egg is derived from the community **"Rust Autowipe"** egg, taken as the known-good base, and it
keeps everything that egg already gets right: the steamcmd install script, the wipe-day
`REGEN_SERVER` / `REMOVE_FILES` mechanism, the Rust+ `APP_PORT`, and — the reason it is the right
base — a **`FRAMEWORK` variable already offering `vanilla | carbon | oxide`**. The operator picks the
framework at deploy time, which is R19's justification restated as a deployment fact: we do not get
to choose.
**The sidecar runs inside the game's own container, and that is the load-bearing part.** A
Pterodactyl server gets its own network namespace, so `127.0.0.1` inside it is genuinely private —
which means **D2 survives untouched**: the game link stays loopback and stays unauthenticated,
because loopback *is* the authentication. The startup command becomes a small wrapper that launches
`rust-link-sidecar` and then `RustDedicated`.
The alternative — a second Pterodactyl server running the sidecar — was rejected for exactly that
reason. Two containers have no shared loopback, so it would force a token and a routable bind onto
the game link. That is the case argued at D2 and overruled; it is not reopened here.
Four things the egg must get right, each of which is a way to get it wrong:
- **A second allocation for `[web].bind`.** The sidecar's HTTP/WS side is the half the website
reaches, so it binds to the container's assigned address on an allocation the panel hands out —
not to loopback. The token is what guards it, exactly as on a hand install.
- **The sidecar's database must never appear in `REMOVE_FILES`.** That variable is the wipe
mechanism, and R12 keeps **all-time rollups across wipes**. A sidecar store swept on wipe day is
the one failure that looks like success: the server comes back, the site repopulates, and every
player's history is silently gone.
- **Stop means stop the game.** The egg's stop command is `quit`, addressed to RustDedicated. The
wrapper has to let the sidecar go down with it rather than outliving it or holding the container
open.
- **The plugin and the sidecar come from a release, never from a copy.** The install script fetches
the pinned pair the same way the installer resolves a bundle — which makes the egg the third
consumer of the protocol-pairing check, not an exception to it.
**It lands in phase 18, beside the installer**, because phase 18 is already "how the shard side
reaches an operator", and one story told twice is how two stories drift apart.
### R21 — both Rust rigs move to Pterodactyl, because one install cannot prove two frameworks
**Decided 2026-09-15 (org lead).** Oxide and Carbon **cannot coexist in one install** — Oxide ships
a patched `Assembly-CSharp.dll` and Carbon requires Facepunch's vanilla one. So R19 cannot be proven
on `D:\rust`, or on any single server, at all.
Both rigs move to the existing Pterodactyl panel at **192.168.0.12** (node `Main`): one server with
`FRAMEWORK=oxide`, one with `FRAMEWORK=carbon`, on the same egg. They are started and stopped as
needed rather than both left running.
This replaces `D:\rust` as the rig of record, and it buys more than parity:
- **The egg gets exercised by every phase**, not only by phase 18. R20's deliverable stops being a
thing written once at the end against a panel nobody has used.
- **It ends the wipe-day maintenance that dominated §4.** `start.bat`'s steamcmd argument ordering,
re-extracting Oxide after every `app_update`, checking `Assembly-CSharp.dll`'s byte size to tell a
half-done Oxide install from a working one — all of that becomes the panel's job, through
reinstall.
- **It is a Linux rig.** Every previous finding came from Windows and Mono; phase 1 spent real time
on a Mono-specific NUL-padded `SocketException.Message`. Production Rust servers are Linux, so the
rig moving there makes findings more representative, and makes any remaining Windows-only
behaviour something we notice rather than depend on.
**A Carbon rig must be a clean install, not a converted one.** Carbon migrates an Oxide install on
first boot — it copies config, data, lang and permission files across. A Carbon rig made by
converting the Oxide rig would start out holding the Oxide rig's state, and would prove less than a
fresh one.
**The access, and the two kinds of key it takes.** `RunicGateway/pterodactyl_claude_api_token` holds
both, one per line: an **application** key (`ptla_…`), which creates and configures servers,
allocations and users and reads eggs but **cannot touch files, power or console**; and a **client**
key (`ptlc_…`), which is where Pterodactyl puts exactly those. An application key is rejected
outright by `/api/client/**` and cannot be widened — they are two credentials, not two scopes of
one. So the deployment loop is three tiers, matched to what each is for:
| What | How | Why that one |
|---|---|---|
| **A release artefact** — the pinned plugin + sidecar pair | The egg's own install script, re-run by a panel **reinstall** | It is the path we ship. Exercising it on the rig is acceptance testing for free |
| **Working-tree iteration** — an uncommitted `.cs` under test | A **client** API key (`ptlc_…`): `files/write`, then `command` to reload | The Pterodactyl analogue of `servuo-plugins/deploy.ps1`, and it carries the same caveat: **if something only works when the push script copies it, it does not ship** |
| **Bulk or binary** — sidecar builds, world files | SFTP on the node, port 2022 | Where the client API's per-file write is the wrong shape |
**Both keys exist and both were exercised on 2026-09-15** — the token file holds them as
`application:` and `user:` lines, and §14 records the rig they built together. The push script itself
lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO.
### R22 — the sidecar is configured from the egg's variables, not from a file the operator edits
**Decided 2026-09-15 (org lead).** What normally lives in `sidecar.toml` moves into the Rust egg's
variables, so an operator on Pterodactyl configures the sidecar in the panel alongside the game's own
settings rather than opening a file manager to edit TOML. One configuration surface, in the place
they are already looking.
**This is nearly free, because the sidecar already does it.** `rust-link`'s `config.rs` documents its
precedence as *environment overrides file overrides defaults* and already reads all five keys from
the environment: `RUSTLINK_GAME_BIND`, `RUSTLINK_SERVER_ID`, `RUSTLINK_WEB_BIND`,
`RUSTLINK_WEB_TOKEN`, `RUSTLINK_DB_PATH` (plus `RUSTLINK_CONFIG` for the file's own path).
Pterodactyl exposes every egg variable to the container as an environment variable, so the mapping is
one-to-one and **no second configuration mechanism is introduced** — the file stays canonical, the
environment overrides it, the egg sets the environment, and the installer (R4) keeps writing the file
exactly as it does now.
Which gives the two halves of the shard side two different config surfaces, deliberately:
| | Configured from | Mechanism |
|---|---|---|
| The **plugin** | the website, Admin → the R18 config editor | D3: it reads `oxide/config/RunicGateway.json`, so it is inside R18 for free |
| The **sidecar** | the panel, as egg variables | R22: `RUSTLINK_*` in the container environment |
That split is right rather than merely convenient. The plugin is configured by the thing it talks to;
the sidecar is configured by the thing that starts it, and on a panel the operator has no shell.
**Three things the variable set has to get right**, each of which is a way to hand somebody a footgun:
- **`RUSTLINK_GAME_BIND` is not operator-editable.** D2 makes loopback the authentication on the game
link; a panel field that accepts `0.0.0.0:7799` is a web form that puts an unauthenticated command
channel on the network. It is set by the egg and marked neither viewable nor editable — the same
posture R18 takes toward the plugin's own `Host`/`Port`, for the same reason.
- **`RUSTLINK_WEB_BIND` is derived from an allocation, not typed.** It has to match the port the panel
actually handed out, exactly as the egg already derives `QUERY_PORT` and `RCON_PORT`. A free-text
bind is a bind that silently does not match the allocation, and the failure is the website never
connecting with nothing in any log to say why.
- **`RUSTLINK_DB_PATH` must point somewhere `REMOVE_FILES` never sweeps.** Already named in R20 and
restated here because this is the decision that makes the path an operator-visible field: the wipe
list and the database path become two settings on the same screen, and they must not be able to
agree.
**The token is the one place the ergonomics are not automatic.** Today the sidecar generates a token
when it finds none and persists it to its config file, which is what makes it secure out of the box;
`--print-config` is how an operator reads it back. A panel variable cannot be filled in by the
program that generates it, so the choices are: ship an empty default and let the sidecar generate and
persist as it does now, with the operator reading it out of the panel's file manager once; or make
the operator paste one in. The existing precedence already supports both — a set variable wins, an
empty one falls through to generation — so this is a default to choose when the egg is built, not a
mechanism to design. **Whichever is chosen, note that a Pterodactyl variable is visible to anyone
with panel access to that server and appears in the container environment**, which is a different
exposure from a `0600` file and should be stated in the operator guide rather than discovered.
**Lands in phase 18 with the rest of R20's egg.**
## 3. Open questions
**None.** Every question this section carried was closed on 2026-09-15, and so was the one open
*request*: the token file now holds both keys, and **both were exercised end to end on 2026-09-15**
(§14).
One correction belongs here rather than being quietly dropped, because the shape of the mistake is
the reusable part. This section briefly recorded that the client key "authenticates and then lists
zero servers", and built a diagnosis on top of it — including a claim that *includes are broken on
this panel*, because `/api/application/servers?include=user` returned an empty list where the same
route without the include had returned six.
**Both claims were wrong, and they were wrong the same way.** The servers were being deleted while
the probing was happening, so two reads minutes apart were reads of two different worlds. The empty
client list was correct. The empty include was correct. Nothing was broken.
The lesson is not "check twice"; it is that **a differential diagnosis across two API calls silently
assumes the state did not move between them**, and on a live panel somebody else is also holding the
controls. Once a server existed, every one of those calls answered correctly on the first try.
*Clans in the base set while the Team provider reads first-party* was confirmed as the intended
reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
provider is fed from the first-party system that actually publishes membership transitions (R5).
*Offline reward grants* was dissolved rather than answered. **R16 changed the noun**: the reward
action grants an entitlement instead of items, and an entitlement does not need the player to be
online. The persisted pending-grant queue that question was weighing is not needed at all.
## 4. The test rig
**Two servers on the Pterodactyl panel at `192.168.0.12`, one per framework (R21).** They replace
`D:\rust`, which was the rig for phases 0 and 1 and whose findings are still recorded in §12 and §13.
| | Oxide rig | Carbon rig |
|---|---|---|
| Panel | node `Main` (192.168.0.12), nest 4 "Rust" | same |
| Egg | ours, derived from **"Rust Autowipe"** (panel egg id 18 is the unmodified base) | same egg |
| `FRAMEWORK` | `oxide` | `carbon` |
| Allocations | game, query, RCON, Rust+, **plus one for the sidecar's `[web].bind`** | same |
Started and stopped as needed rather than both left running; the other servers on the node are
shut down, which is what makes two ~20 GB Rust installs fit a 128 GB disk.
**The Carbon rig is a clean install, never a converted one.** Carbon migrates an Oxide install on
first boot — config, data, lang and permission files all come across — so a Carbon rig made by
switching `FRAMEWORK` on the Oxide rig would start out holding the Oxide rig's state and would prove
strictly less.
**What the panel changes about how work reaches a rig.** The token at
`RunicGateway/pterodactyl_claude_api_token` is an *application* key: it manages servers, allocations
and users, and it **cannot write a file, press a button or run a console command** — Pterodactyl puts
those on the client API. R21's table has the three tiers; the short version is *release artefacts
arrive by reinstall, iteration needs a client key, bulk goes over SFTP on port 2022.*
**Two facts about this panel that are easy to trip over:**
- `/api/client/**` returns **403 `AccessDeniedHttpException`** for the application key — a clear
error, but only if you are expecting it. It is not a permissions grant that can be widened.
- The application API has **no egg-write endpoint at all** (`/api/application/eggs` is a 404; eggs
are read through `/api/application/nests/{nest}/eggs`). Importing a new egg version is an admin-UI
or `php artisan` operation, so the egg's release artefact is a JSON file a human imports — which is
also exactly how an operator will consume it.
### What moving off the workstation retires
Everything below was true of `D:\rust` and is kept only because it explains findings in §12. **None
of it is maintenance any more** — the panel's reinstall does the same work correctly.
- **`start.bat` never updated anything.** steamcmd requires `+force_install_dir` **before** `+login`
and the script had it after, so the flag was discarded, the update ran against steamcmd's own
directory, and the job errored every single time (`Error! App '258550' state is 0x486`). That — not
the path, which was the first and wrong diagnosis — is why the rig fell a wipe behind.
- **Re-extract Oxide after every `app_update`.** Oxide ships a *patched* `Assembly-CSharp.dll` and a
Steam update restores Facepunch's, but the update does **not** remove `Oxide.Core.dll` and friends
— so a half-done install still *looks* Oxided while loading no plugins and raising no hook. The
tell was file size: on build 25230300, vanilla 9,758,544 bytes against Oxide 2.0.7716's 9,953,280.
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere** — its
`Assembly-CSharp.dll` is 6,842,880 bytes, a hard downgrade over a live install.
- **A wipe keeps `server/server1/cfg/`**, which holds `users.cfg` and therefore the `ownerid` line.
Delete the whole identity directory and you silently remove the operator's own ownership along with
the map. This one still applies — it is the game's shape, not the host's, and it is why the egg's
`REMOVE_FILES` list is worth reading carefully rather than trusting.
**Rust force-wipes on the first Thursday of the month, and both frameworks rebuild to match.** "Is
the rig current" stays a recurring question rather than a setup step; what changed is that the answer
is now a reinstall rather than a sequence of manual steps that can half-succeed.
## 5. The phases
**Twenty phases, roughly doubled from the first draft** — the contract audit in §7 is why, and the
honest reading is that the first list was a game-bridge plan with a website module bolted on, where
the kit treats the module as the bulk of the work.
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–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.
**R19 and R21 do not add a phase — they change what "done" means for several.** Both rigs exist from
phase 3 onward, so from phase 3 a criterion is met when it is met **on both frameworks**, and a
finding that holds on only one is a finding either way. Phase 2's release artefacts and phase 18's
egg are the two places the second framework is visible in the deliverable rather than only in the
proving.
Each phase ends with its findings written down, as every workstream here does.
| # | Phase | Repos | Done when |
|---|---|---|---|
| 0 | **The rig.** ✅ **Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | 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.** ✅ **Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | 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.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. 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. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | 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). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 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). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | 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 `<Mod>/x.json` is found and reloads the right plugin |
| 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* ✅ **Built 2026-09-23 — plan, as built and findings in §24** (D47–D58). Walked on the Oxide rig end to end and on Carbon hook by hook; the game's 100-clan ceiling accepted (D55); **one module per site** became a core rule on the way (§24.5). 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). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59–D68).** The criterion walked on the Oxide rig (two authorised owners alerted once each; bystander, raider and an unlinked owner told nothing), and the same plugin loaded on Carbon; a live walk found that every generic notice never said which server (§25.6). Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §26 (D69–D72).** The link check behind personal streams asked `module-uo` and locked the raid alert on every Rust site; it now asks the site's own module and holds back only push-on. A tickle is titled from the inbox row it names, and two links stopped opening the browser. **The relay hop was walked on `ntfy.sh` for the first time on any site**: a rig raid reached the emulator in 19 seconds, and after push was switched off in the app the next raid enqueued in-app only | Android-app + docs | A Rust notification arrives on a phone and can be switched off there |
| 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). ✅ **Built and walked 2026-09-24 on both rigs — as built and findings in §27.5–27.7.** 21 keys walked live, two `spawn.max_*` left out; two defects of its own fixed (the Oxide grant owner, `EventsEnabled` not applying); four found outside it and raised. [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking |
| 13a | **Events: the world verbs** (§9, R17, **split by D80**). **Plan of record in §28 (D80–D95).** The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates; the monument and prefab option sources; the plugin's ownership registry, keyed by the idempotency key; and **`reconcile()` with the boot-id and wipe watch calling `ctx.events.reconcile()`** (§11.1). Protocol 9 | all 3 + docs | A wipe reconciles the ledger instead of stranding it, and each world verb's teardown is observed in the game |
| 13b | **Events: the rewards** (§9, R3, R16). The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | 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 built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
| 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework |
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change, and Pterodactyl is a hosting claim the site can now make); the Integration-kit question R2 raised; and whether the kit owes a reader anything about **supporting two mod frameworks at once** (R19) — a shape it has no chapter for either | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
### Why the lease comes before the reward action
The stated priority is Kits rewards, and this plan still schedules a lease first (12 before 13). [kit][kit] ch. 5 is
explicit about it and the reasoning survives restating: a lease is *a value that already existed,
changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action
makes something that did not exist. The lease proves the whole command path — correlation, the
idempotency key, `budgetMs` against the client timeout, the deadline the game enforces on its own —
before anything hands out loot. It is a cheaper place to find all four of chapter 5's invisible
failures.
Reversible on request.
### What phase 13 must declare honestly
> **Rewritten 2026-09-15 by R16.** This section previously argued that a kit reward could only be
> `reversible: 'none'`, because there is no honest way to un-grant loot a player has already spent.
> That was correct *about a direct grant* and R16 stopped doing direct grants. The reasoning is kept
> below in its corrected form because the shape of the mistake is the reusable part: **the action was
> declared around the wrong noun.** What the event makes is not loot; it is an entitlement.
**The reward action grants an entitlement, and an entitlement is reversible.** `revert` revokes the
permission, removing one that is not there is a success, and it is idempotent by construction — so
**`reversible: 'ledger'` is the honest declaration**, and core's ledger sweep does real work on every
terminal path.
One consequence to state rather than discover: **a player who redeemed before the revert keeps the
items.** That is correct and not a hole. The ledgered resource is the *grant*, and reverting it
withdraws the entitlement rather than the consumption — the same way cancelling a coupon does not
un-eat the meal. An operator reading the run console should see that distinction in the wording.
**`cost()` counts permission grants, and unlike a kit count it is exactly knowable before dispatch.**
That removes the whole class of problem chapter 5 §4 warns about: there is no "declare the maximum
because you cannot know until the answer comes back". One recipient is one grant. Core prices `cost`
before dispatch and never reconciles it, so being able to count precisely is worth more than it
sounds.
**And the idempotency key largely stops mattering here**, which is the tidiest part of R16. Chapter 5
§2 draws the line itself: *"A key is for a write whose repetition would be a second EFFECT —
creating, granting, announcing. A write that SETS a value to X is idempotent by its own nature."* A
permission grant is a set. Pass the key through anyway — it costs nothing and it is what the
contract expects — but the failure mode it exists to prevent, a socket hiccup producing a second set
of everything, no longer has a way to happen.
The monthly wipe is still the case `revert`'s tolerance rule was written for: if a wipe or a rebuilt
host clears Oxide's permission store, every ledgered grant is invalidated at once and **"gone, and
that is fine" is a success**. Note this is also where R2 pays for itself twice — the site re-pushes
its whole permission set on the next connect, so an entitlement an event granted comes *back* rather
than being quietly lost.
## 6. Risks worth naming now
- **Hooks bind by name and arity, by reflection, with no compile-time check.** A misspelled hook is
never called, silently, with no warning at load — the single most common way a Rust plugin does
nothing. The plugin must log which of its expected hooks have fired at least once, so a hook
Facepunch renamed on a wipe is visible rather than mysterious. See [`README.md`](README.md) §2.
**R19 gives that mechanism a second job:** it is also the only trustworthy answer to "does this
hook exist on Carbon", since two published catalogues disagreeing is evidence about the catalogues
and not about the frameworks.
- **The cheapest way to make Carbon required is to do it by accident.** Carbon's extra 30 hooks, its
23 extra convars and its `bool`-returning permission API are each individually useful, individually
small, and collectively a framework lock-in nobody decided on. R19's refusal is written down
because it will be re-argued, once per convenience.
- **Two rigs is twice the state that can be quietly wrong.** A finding proven on the Oxide rig and
assumed on the Carbon one is exactly the failure this project keeps finding in source-read claims.
From phase 3, "done" means done on both, and a phase that could only check one says so.
- **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 both
frameworks, and invalidates every ledgered resource. Phases that end near one should expect to
re-verify rather than assume. **Carbon's self-updating and rolling release tags mean the Carbon rig
may move under us between two runs on the same day**, where an Oxide build number at least says so.
- **The old rig's RCON password was `letmein` in plaintext with `rcon.web 1`.** Acceptable on a
loopback dev rig behind a home firewall, and it must never be the shape anything published copies —
which now matters more, because the panel rigs are reachable on a LAN address and **the egg is a
published artefact that people will copy defaults out of.**
- **The panel is the rig and the deliverable at once.** Convenient, and a way to prove the wrong
thing: a rig hand-tuned through the panel UI stops testing the egg. Anything a rig needs belongs in
the egg or in the push script, never only in a server's saved configuration.
## 7. Contract coverage audit
Added 2026-09-15 after re-reading the whole kit rather than only chapters 3-5. **The first draft of
§5 was built from the game-facing chapters and under-planned the website module by a wide margin** —
the template makes eight of the ten non-event registrations and the plan covered three. Every element
is listed with the phase it now lands in; the column worth reading is *"was it in the first draft"*,
because that is the shape of the mistake.
### The server handshake — ten registrations plus two hooks (ch. 2)
| Call | In the first draft | Phase |
|---|---|---|
| `registerRoutes` | yes | 4 |
| `registerTeamProvider` | yes | 9 |
| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 12-13 |
| `onBoot` / `onShutdown` | implicit only | 1 |
| `registerExtension` | **no** | 4 (`site.footer.status`), 6 (`admin.users.detail`) |
| `registerNotificationStreams` | **no** | 10 |
| `registerEventTriggers` | **no** | 10 |
| `registerAudiences` | **no** | 10 |
| `registerEngagementSeeds` | **no** | 10 |
| `registerAnnounceLeg` | **no** | 10 |
| `registerPostHook` | **no** | 10 |
| `registerSlashCommands` | **no** | 16 |
Triggers, audiences and seeds are one phase because they are a **matched set** in
`template/server/index.js`, not three independent gaps — see R7.
### The bundle's own parts
| Part | In the first draft | Phase |
|---|---|---|
| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 4 |
| `mounts` and prefix choice | **no** | 1 (R14) |
| `schema.sql` | yes | 1, 3 |
| `purge.sql` | **no** | 1 |
| `swagger-fragment.json` + generator + `check:swagger` | **no** | 1 |
| `vite.config.js` aliases / shims / `checkExternals` | **no** | 1 |
| `checkImports.js` | **no** | 1 |
| `release.yml`, install manifest, `sha256`, host allowlist | **no** | **2** — there was no packaging phase at all |
Phase 1 carries most of these on purpose. [kit][kit] ch. 1's whole argument is to get every seam
working at once with almost nothing in them, so that afterwards you break exactly one at a time.
### The client half
| Part | In the first draft | Phase |
|---|---|---|
| `registry.registerRoutes` / `registerNav` | yes | 4 |
| `declareModuleSlot` | **no** | 9 — the kit: *"you will need it the moment your game has anything like a guild"* |
| `registerFeatureProvider` | **no** | 4 |
| UI kit discipline (`PublicLayout` `shell`, `PageHeader` props) | **no** | 4 |
### Beyond the module
| Item | In the first draft | Phase |
|---|---|---|
| Sidecar rpc correlation | implicit | 1, stated |
| Asset bridge (ch. 3 §2b) | **no** | 14 — **map image only** (R9) |
| `.profile` landing page | **no** | 19 |
| `runicgateway.com` | **no** | 19 |
| Android app | **no** | 5, 8, 11, 15 (R10) |
| `docs/` | yes | every phase, plus 19 |
**What the audit cost the schedule: twelve phases.** That is the honest number, and it is worth
recording because the under-planning had one cause — reading the chapters that describe the game
bridge and treating the module as the thin part, when the kit says in its first paragraph that the
website module is most of the work.
## 8. Questions the audit raised — all answered
Every question §7 produced was put to the org lead on 2026-09-15 and answered the same day. They are
recorded as R7–R14 in §2 rather than repeated here:
| Question | Answer | Decision |
|---|---|---|
| Notifications and engagement in v1? | the full matched set | R7 |
| How many servers does the UI support? | multi-server from the start | R8 |
| The asset bridge? | **only** the live map — not item icons, not skins | R9 |
| Android in this workstream? | full app, capability-driven, trailing by one phase | R10 |
| Discord slash commands? | a small read-only set | R11 |
| What survives a wipe? | per-wipe detail plus all-time rollups | R12 |
| Extension slots? | `admin.users.detail` **and** `site.footer.status` | R13 |
| Which mount prefixes? | `/rust` on all three tiers | R14 |
§3 is empty; both were closed on the same day.
## 9. The event catalogue
Added 2026-09-15. **Phases 12–13 described the event *mechanism* and never the *catalogue*** — one
budget and one lease as a proof of life, which is a skeleton rather than a product. This section is
what the module actually declares.
[`EVENTS.md`](../../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.
> **Corrected by phase 12 (§27, 2026-09-23).** Vanilla Rust has **no** gather, craft or smelt rate
> convar, so those three leases have nothing to set and are out (D74). `rust.group.membership`
> became the group-wide `rust.group.permission` (D75). Every lease's target names its server (D73).
> And *"caps are per server"* holds only for a run an admin starts by hand: a scheduled run always
> has the empty scope. The tables below are the plan as first written.
>
> **Corrected again by phase 13 (§28, 2026-09-24).** `rust.zone.open` is scheduled (13a, D86), a
> location is a monument by kind and instance or raw coordinates (D87, D93), `prefab.place` covers
> crates and NPCs with NPCs on their own `rust.npcs` budget (D88, D89), and a reward's recipients
> come from a participation tally the game keeps (D81).
### Budgets — what core counts and bounds
| Dimension | Counts |
|---|---|
| `rust.prefabs` | objects placed into the world by a run |
| `rust.zone.minutes` | zone time held — real since R17 |
| `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) |
| `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
never blocks the form.
### Leases — values borrowed with a deadline
The heart of it, and the thing to build first.
| Lease | Value |
|---|---|
| `rust.rate.gather` | gather rate multiplier |
| `rust.rate.craft` | craft speed |
| `rust.rate.smelt` | smelting speed |
| `rust.rate.decay` | decay scale |
| `rust.time.night` | night length |
| `rust.population.<kind>` | spawn population multipliers |
| `rust.group.membership` | **a time-limited permission group — weekend VIP** |
**`rust.group.membership` is the one §H names that R16 did not, and the pair is the whole design.**
R16 settled that a *permanent* earned entitlement is an **action** with `reversible: 'ledger'` — grant
the kit's permission, revert revokes it. §H settles that a *time-limited* group is genuinely
**`core.lease`** — held with a deadline the game enforces on its own, restored when it expires
without core having to come back. Same underlying permission mirror (R2), two different shapes,
and choosing the wrong one is the mistake: a weekend VIP implemented as a grant is a VIP who stays
one for ever if the website goes away.
**Every key gets verified live before it is advertised** — apply, observe in the running game,
restore, per key. §H's claim that Rust convars are live by default is an argument for *expecting*
them to work, never a substitute for checking. A value the server reads once at boot applies
cleanly, reads back cleanly, and does nothing at all, and neither core nor review can catch it.
### Actions — verbs a run performs
| Action | `risk` | `reversible` | Notes |
|---|---|---|---|
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) |
**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
> **Corrected by §25 (2026-09-23).** The table below is the catalogue as first planned. The one that
> shipped is §25.2: `self` is not a ceiling core has, `rust.clan.member.added` duplicates core's own
> Team trigger, `rust.kit.entitled` waits for phase 13, and the two player audiences were one set.
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.
## 11. Second contract pass — `MODULE_API.md` read member by member
Added 2026-09-15, after docs#249 merged. §7 audited the plan against the **Integration Kit** and the
**template**; this pass reads [`MODULE_API.md`](../../website/MODULE_API.md) itself, enumerating every
member rather than grepping for registration names. It found one regression, one mispriced decision,
one missing declaration and a set of `ctx` members the plan had never mentioned.
### 11.1 The regression: `reconcile` was dropped
**`ctx.events.reconcile()` and an action's `reconcile()` appear nowhere in this document.** The
twelve-phase first draft had them — *"phase 6: reconcile, and the boot-id watch"* — and the rewrite to
twenty phases lost them. That is a regression in the plan, not a decision.
It matters **more** for Rust than for the game the contract was written against. [kit][kit] ch. 5
rates `reconcile` the one omission that is *"merely a lower standard rather than a broken promise"* —
but that judgement assumes a world that persists. **Rust wipes monthly, and a wipe invalidates every
ledgered resource for that server at once.** Core cannot tell a wedged sidecar from a game that
rebooted and lost everything an event made: it sees `{ ok: false, retry: true }` either way. It asks
once, at its own boot, and otherwise **waits to be told**.
`ctx.events.reconcile()` is being told, and the thing that triggers it is a **watch on the game's boot
id changing** — which is also the only way to tell a game restart from a sidecar reconnect. They are
not the same event and the second loses nothing. Two rules ride with it: anything that is not an
explicit `{ ok: true, inForce: [...] }` **leaves the ledger alone** — "I do not know" is never read as
"it is gone" — and a resource reported missing becomes `orphaned`, not `reverted`, because nobody
asked for it to go.
**Restored to phase 13**, after the actions exist, with the boot-id watch as its trigger.
### 11.2 R9 was mispriced: the visibility framework is `module-uo`'s, not core's
R9 says the map's per-layer switches work *"through the existing visibility framework
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md))"*, which reads as reuse. **It is not
reuse.** §6.3 records that `shardVisibility` is **module-owned**, and the tree confirms it — the util,
both models, the admin controller and its tests all live under `module-uo/server/`, and there is
**nothing** by that name left in `website/server`.
§2.7 forbids a module requiring anything outside its own directory, so `module-rust` cannot import a
line of it. **It builds its own**, informed by UO's design and its document but sharing no code.
That is a real cost R9 did not price. It is not a reason to change the decision — per-layer switches
are still right, and `SHARD_VISIBILITY.md` is still the design to learn from — but phase 14 carries
a visibility layer of its own rather than a configuration of somebody else's.
### 11.3 `extensions` is a declared field, not just a call
R13 claims two slots and never says where they are declared. **`module.json` has an `extensions`
array** (§2.1, optional), and the dry run's own manifest carried `"extensions": ["admin.users.detail"]`.
Like `mounts`, it is a statement of surface that the loader holds against reality — so
`admin.users.detail` and `site.footer.status` are declared there as well as registered. Phase 1 adds
it to the list of `module.json` fields that must be got right.
> **Corrected in phase 1 (§13.2), and it is half wrong.** The loader checks only that a named slot EXISTS (`loader.js:681` → `registries.hasSlot`); `checkDeclared` covers `mounts` **alone**, so a declaration with nothing behind it loads cleanly and means nothing. And only ONE of R13's two slots can be declared here at all — `admin.users.detail` is the only server slot core declares, while `site.footer.status` is a CLIENT slot registered from the chunk, and naming it in `extensions` fails the load outright.
### 11.4 The `ctx` members the plan had never named
`ctx` has **29 members** (§2.3). The plan named a handful. The ones that change work:
| Member | Where it lands | Why it matters |
|---|---|---|
| **`ctx.secretBox`** | 1 | Each configured server's sidecar token is a secret at rest. Core encrypts its own (AES-256-GCM, write-only in the API, never returned to any client) and hands a module the same facility — so R8's several tokens get the platform's existing posture rather than a new one |
| **`ctx.middleware.rateLimit`** | 6 | R1 requires the link code be rate-limited. This is the mechanism; `accountChangeLimiter` sits beside it for the account-facing half |
| **`ctx.uploads`** | 14 | Where R9's map image actually lands. The plan described fetching it over the bridge and never said where it goes |
| **`ctx.activity.log`** | 7, 7b | R2's permission changes and R18's config writes both owe an audit trail. Core has an activity log; neither needed inventing one |
| **`ctx.teams.publish`**, **`ctx.teams.activity.push`**, **`ctx.teams.reconcile`** | 9 | Teams is more than the provider. The plan named only `registerTeamProvider`, which answers core's questions — these are how a module *pushes* a change and asks for reconciliation |
| **`ctx.posts`** | 10 | The CMS surface behind `registerAnnounceLeg` and `registerPostHook` |
| `ctx.events.emit`, `ctx.inbox.push`, `ctx.push.publish` | 10 | The three send paths §10's catalogue implies and never named |
| `ctx.users.getById`, `ctx.settings.*`, `ctx.validator`, `ctx.db.query`, `ctx.paths.moduleRoot`, `ctx.log`, `ctx.express`, `ctx.auth.getUserFromRequest`, `ctx.site.baseUrl`, `ctx.moduleId` | throughout | Ordinary plumbing; listed so the narrowing is visible |
`ctx` is **a curated list, not core's internals** — `ctx.auth` is one function rather than core's whole
auth facade, because minting a session is core's job and a module needs to *read* one. Expect to want
something that is not there; that is a minor-version conversation, never a reason to reach around it.
### 11.5 §6.8 — a trigger, a rule and an audience outlive the module that declared them
A constraint on phase 10 and on purge that the plan did not carry.
`engagement_rules.trigger_id` is a plain `VARCHAR` — **no foreign key, no cascade** — deliberately, so
a module can be removed and reinstalled without destroying an operator's rules. The consequence:
- **A rule whose trigger is unregistered shows `dormant`** — never an error, never auto-deleted.
- **The same for an unregistered audience**: it resolves to the empty set and shows dormant, which is
**not the same answer as "resolved to nobody"** and must not be rendered as if it were.
The failure that prevents is exact: **an id that stops resolving must never silently become a send to
a different set of people.**
### 11.6 The two client lists, in full
Recorded because §7 said "UI kit discipline" without saying what is in it. Both are **closed and
curated** — adding a member is a minor version bump, changing an existing prop is a major one.
**`registry`** — `registerRoutes`, `registerNav`, `registerExtension`, `registerFeatureProvider`,
`declareModuleSlot` (1.6.0), plus the read side, `routesFor` and `featureProviders`.
**`ui`** — `PublicLayout`, `PageHeader`, `Loading`, `ErrorState`, `EmptyState`, `useAsync`, `useAuth`,
`useSite`, `Slot` (1.6.0). Anything else — tables, chips, tabs, editors — **your chunk carries it**.
### 11.7 One confirmation for R10
§2.9: `GET /api/v1/public/modules` returns **only `started` modules**, with four fields and no state,
no failure stage and no failure reason. A `disabled` or `startup_failed` module is simply **absent**.
So R10's capability probe already has the behaviour the app wants: a Rust module that failed to boot
makes the app render a site *without* those screens, rather than one advertising screens that `503`.
The app needs no failure handling for this case because core does not expose the failure.
## 12. Phase 0 as built — the rig, 2026-09-15
The rig is current, the base set runs, and **both acceptance criteria are met**. Six things were
learned that the plan had either wrong or had never asked, and four of them change work in later
phases.
### 12.0 What the rig is now
| | Before | After |
|---|---|---|
| Server build | `24613624` (2026-08-13) | **`25230300`** (2026-09-10) |
| Oxide | `2.0.7585` | **`2.0.7716`** (`OxideMod/Oxide.Rust`, 2026-09-11) |
| World | seed 1234 save v287, previous wipe | regenerated for this wipe; `cfg/` preserved |
| `oxide/plugins/` | empty | Kits 4.4.9 · Clans 0.2.10 · Popup Notifications 0.2.1 · Zone Manager 3.1.14 |
All four compiled and loaded first time on the new build, at exactly the versions R6 and R17 name —
pulled fresh from `https://umod.org/plugins/<Name>.cs`, which still serves those versions and needs
no Cloudflare workaround. Server protocol `2633.288.1`.
The Oxide permission store survived the update untouched (`oxide/` is not a Steam depot directory),
so `76561198038695917` is still in `default` and `admin`.
Two instruments were built and are kept in the phase-0 scratchpad rather than committed: a
dependency-free **WebSocket RCON driver** (Node's global `WebSocket`, no `ws` package), and
**`RGProbe.cs`**, a throwaway Oxide plugin that exposes Oxide's permission API and ZoneManager's
by-name API as console commands. The probe is what made §12.2 and §12.4 observable; phase 1's plugin
skeleton can start from it.
> **One thing the RCON driver had to learn.** Oxide tags its own `Puts()` output and its warnings
> with the **identifier of the command being run**, so a first-match-wins client reads a plugin's log
> line as if it were the reply and discards the real one. It cost two wrong readings before it was
> spotted. Collect every frame in a window; do not correlate one reply per identifier.
### 12.1 The rig's own script was broken in a way the earlier diagnosis missed
Recorded in §4. In short: `start.bat` put `+force_install_dir` **after** `+login`, steamcmd discarded
it, and every update run in the rig's history errored out without updating anything. The 2026-09-15
"fix" changed the path and left the order, so it fixed nothing.
The Oxide re-install in §4 is **not** a finding — pairing a server update with an Oxide re-install is
the routine every Rust host already follows, and saying otherwise would be this plan talking down to
its own audience. One narrow consequence is still worth carrying to **phase 18**: because
`app_update` leaves `Oxide.Core.dll` and the rest in place, a `doctor` check that tests for `oxide/`
or for Oxide's assemblies **passes on a server that is mid-routine**. Compare the
`Assembly-CSharp.dll` against the Oxide build instead, so `doctor` reports the real state rather than
a directory listing.
### 12.2 Four rules the R2 permission push must obey
Verified live against the real store, granting and revoking through both the console command and the
API:
1. **`permission.GrantUserPermission` silently no-ops for an unregistered permission.** `void`, no
throw, no log. The console `oxide.grant` at least answers `Permission 'x' doesn't exist`; the API
path R2 uses says nothing at all. This is the finding with teeth — see R2.
2. **A permission exists only because a loaded plugin registered it.** Kits registers `kits.admin`
and, dynamically, **every kit's `RequiredPermission`** (`Kits.cs:1225`, `:2895`) — which is what
makes R16's entitlement model real. Unload Kits and those names stop existing.
3. **`RegisterPermission` warns about a foreign prefix but registers anyway.**
`Missing plugin name prefix 'rgprobe' for permission 'someplugin.vip'` is a warning, not a
refusal — the permission was created and granted successfully. So the site *can* make a grant
stick for a plugin that is not currently loaded, at the cost of a console warning. Whether it
*should* is a phase 7 decision; the mechanism exists.
4. **A player who has never connected is in no group, but can hold direct grants.** A grant to an
unseen SteamID64 works and reads back immediately. Group membership does not exist for them yet,
so **anything the site expresses as group membership does not reach a player until their first
connection**, while a direct grant does. R16's offline entitlement is safe; a group-shaped
entitlement is not.
Point 4 is the one to carry into phase 7's design: grants and groups have **different reach** for
offline players, and the site's model currently treats them as two spellings of the same thing.
### 12.3 R5's claim about the Clans plugin was a grep artefact
Corrected in R5. The plugin raises nine hooks, not three, and three of them carry full member lists;
the six that were missed are invisible to a literal search because the hook name is a `const` at the
call site. The decision stands on a different reason — first-party is what every server has, the
plugin is optional — and phase 17 gains event-driven leadership as a sharpening rather than a
replacement.
Two smaller things from the same read, both worth having before phase 9 and 17:
- **`Clans` raises the same hook name twice per transition**, once Rust-typed
(`string, ulong, List<ulong>`) and once Universal-typed (`string, string, List<string>`), plus two
deprecated arities. Oxide binds by name **and** arity, and both live forms are arity 3 — so a
loosely typed subscriber catches both and double-counts every join and leave. Type the parameters
precisely and pick one.
- **`Clans` calls `API_RegisterThirdPartyTitle` itself.** R15's BetterChat integration will be the
*second* title provider on any server running both, not the first.
Also confirmed, since the plan rests on it: the first-party set is exactly the **seven** hooks in
`agent/hooks.tsv`, all "no return behavior", with no promote and no leader-changed.
### 12.4 ZoneManager's owner scoping is narrower than R17 assumed
Corrected in R17. `EraseTemporaryZone(owner, id)` refuses only when the zone has a *different*
owner; an **unowned** zone — every permanent zone, including every zone an operator made by hand — is
erased by anyone and returns `true`. Phase 12 must gate erasure on its own id map.
Three more things the source and the live rig agreed on:
- **ZoneManager's entire API is plain private methods**, no `[HookMethod]` anywhere in 3.1.14 — so
`Call()` by name is the only way in, and a typo is silence. Confirmed working live for
`CreateOrUpdateZone`, `CreateOrUpdateTemporaryZone`, `EraseTemporaryZone`, `GetZoneIDs` and
`GetPlayersInZone`. The three-conventions finding holds: Kits declares `[HookMethod]` (23 of them),
ZoneManager declares nothing, BetterChat will use `API_` prefixes.
- **`GetPlayersInZone` cannot distinguish an unknown zone from an empty one** — both return an empty
list, not null. The participation ledger R17 wants to feed therefore cannot use this call alone to
answer "is this zone still there", and must check `GetZoneIDs` separately. This is the same
absence-of-an-answer / answer-of-absence trap earlier phases of other workstreams hit.
- **NPCs never appear in a zone's player list.** `baseEntity is BasePlayer { IsNpc: false }` routes
them to the zone's *entity* list instead. Useful to know before designing a condition that counts
"players at the monument" on a server with scientists.
### 12.5 The criterion is closed, and it revealed two ceilings on the rig
> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it
**Both halves done**, the second with the org lead connected. The zone was created on the player's
own position, and ZoneManager reported both transitions live:
```
[probe] ENTER zone=rgtest player=76561198038695917 (whitlocktech)
[probe] zone=rgtest occupancy=1 [76561198038695917:whitlocktech]
[probe] EXIT zone=rgtest player=76561198038695917 (whitlocktech)
```
The exit was produced by **moving the zone off the player** rather than walking them out —
`CreateOrUpdateZone` on an existing id relocates the trigger volume and fires `OnExitZone` as it
leaves. Useful for testing presence without choreographing a person.
So **R17's "presence transitions as events" is verified**, which is the claim the participation
ledger and the advance conditions both rest on.
Two ceilings surfaced on the way, and both constrain later phases:
**1. No console session can observe a gate.** The standard idiom is
`return !player || permission.UserHasPermission(...)` — a command from RCON has no `BasePlayer`, so
the console is unconditionally allowed. Anything whose acceptance needs a permission to actually
*refuse* somebody needs a client attached.
**2. An admin account cannot see a refusal either — from most plugins.** The bypass is not uniform,
and the difference decides which phases can be demonstrated on the org lead's own account:
| Plugin | Admin bypass | Demonstrable as owner? |
|---|---|---|
| Popup Notifications | `player.IsAdmin \|\|` — hard | **No** |
| Zone Manager | `authLevel > 0 \|\|` — hard | **No** |
| Kits (`RequiredPermission`) | `Configuration.AdminIgnoreRestrictions && IsAdmin(player)`, and Kits' own `IsAdmin` is the `kits.admin` **permission**, not auth level. The shipped default is **`false`** | **Yes** |
So **phase 13 is demonstrable on this rig as it stands** — R16's entitlement gate applies to a server
owner like anyone else. **Phase 7 is not**, if its acceptance is "a grant made on the website gates a
third-party plugin in-game" against Popup Notifications or Zone Manager: that needs a **second,
non-admin Steam account**. Worth arranging before phase 7 rather than discovering there.
### 12.6 R18's trees, as they actually look
The four base plugins wrote their configs on first boot, so R18's two trees can be compared against
something real rather than predicted:
```
oxide/config/ Clans.json Kits.json PopupNotifications.json ZoneManager.json
oxide/data/ clan_data.json Kits/kits_data.json Kits/player_data.json
ZoneManager/zone_data.json
oxide.users.data oxide.groups.data oxide.covalence.data oxide.lang.data
```
R18's inventory of `data/` was exactly right. One nuance worth correcting, though: R18 motivates the
**recursive** walk with *"plugins nest (`config/<Mod>/x.json` and deeper)"*, and on a fresh install of
the base set **`config/` is flat — it is `data/` that nests.** The recursive walk is still correct
(other plugins do nest configs), but the nesting the plan cites as its reason is currently visible
only in the tree it must never walk.
And a reason to hold that boundary harder than R18 states: **`oxide/data/` is where Oxide keeps its
own permission store** (`oxide.users.data`, `oxide.groups.data`). A config editor that strayed one
directory over would be editing R2's mirror underneath itself.
## 13. Phase 1 as built — the transport, 2026-09-15
**Both criteria met.** A `server.hello` produced by the live Rust rig travelled game → sidecar →
module → the public website API; killing the sidecar left the game untouched; all five guards are
green on the module skeleton. Three repositories have their first commits:
[`Rust-Link`][rl], [`Rust-Plugins`][rp], [`Module-Rust`][mr], plus
[`rust-link/PROTOCOL.md`](../../rust-link/PROTOCOL.md) and
[`INTEGRATION.md`](../../rust-link/INTEGRATION.md) here.
### 13.0 The three org-lead decisions this phase needed
None of them were settled by §2, and each would have been expensive to reverse later.
- **D1 — the Rust bridge's docs live at `docs/rust-link/`**, a new top-level directory mirroring
`docs/link/`, rather than under `modules/rust/`. It keeps the `uo`/`link` symmetry and keeps
*module* docs separate from *bridge* docs, which are different contracts with different audiences.
- **D2 — loopback is the only trust boundary on the game link**, exactly as on the ServUO bridge:
no token between plugin and sidecar. The alternative was argued on the grounds that Rust servers
are far more often on GSPs than ServUO shards are, so binding to something other than `127.0.0.1`
is a realistic operator need. **Overruled**, and the consequence is written into the plugin's
class docs and `PROTOCOL.md` §1.1: moving that bind puts an unauthenticated command channel on the
network, and it is documented as the mistake rather than defended against.
- **D3 — the plugin reads its settings from Oxide's own config file**
(`oxide/config/RunicGateway.json`) rather than a standalone `Bridge.cfg`-shaped file. It is
idiomatic for Oxide, and it lands inside R18's phase-7b config editor for free. The argument
against — that editing `Host`/`Port` from the website could cut the link carrying the edit — is
real and is now phase 7b's problem to guard rather than a reason for a second config mechanism.
### 13.1 What phase 1 deliberately did NOT register
The module registers **routes on three tiers and the two lifecycle hooks, and nothing else**. No
Team provider, no triggers, no audiences, no engagement seeds, no notification streams, no event
budgets/leases/actions/option sources, no extension slots.
That is asserted by a test (`nothing is registered that has nothing behind it yet`) so that removing
it is deliberate. The reasoning is worth keeping: **a declared trigger nothing emits and a declared
slot nothing fills are both surfaces an operator can configure and then wait on**, which is worse
than an absent one, because the absence is visible.
### 13.2 §11.3 was half wrong about `extensions`
§11.3 reads `module.json`'s `extensions` as *"declared, not just registered… like `mounts`, held
against reality by the loader"*. Reading the loader says otherwise, in two ways:
1. **The loader never checks that a declared slot was filled.** `loader.js:681` asks only
`registries.hasSlot(slot)` — does this slot *exist*. `checkDeclared` covers `mounts` **alone**,
in both directions. So a declaration with nothing behind it loads cleanly and means nothing.
2. **Only one of R13's two slots can be declared there at all.** `admin.users.detail` is the only
server slot core declares (`router/v1/admin/users.router.js:202` is the sole `declareSlot` call
outside the registry). `site.footer.status` is a **client** slot, registered from the chunk —
naming it in `extensions` fails the load with `unknown extension slot "site.footer.status"`.
So the field is written when phase 6 registers the server half, and never before. Phase 4's
`site.footer.status` work does not touch it.
### 13.3 Five defects the rig found that no test could
Each of these was written from source reading, shipped, and then corrected by a live Rust server.
The hit rate is the phase-0 lesson repeating.
1. **A disconnect was silent in the game console.** The link teardown log sat in `LinkLoop`'s
`catch`, and a connection that ends because the *reader* saw EOF leaves the writer to exit
cleanly — nothing throws, so nothing is logged. The fix captures `_connected` at the top of the
`finally`. Worth generalising: **a log in a catch block only covers the failures that throw**, and
an orderly shutdown of a peer is not one of them.
2. **`Unload` blocked the main thread for 1.9 seconds**, which Oxide reports as
`Calling 'Unload' on 'RunicGateway v0.1.0' took 1918ms`. The reconnect backoff was
`Thread.Sleep`, and `Unload` joins the link thread — so every plugin reload froze the server for
up to the backoff. Waiting on the same `AutoResetEvent` that `Unload` already signals makes it
immediate. **The ServUO plugin has the same `Thread.Sleep`**, and it is survivable there only
because ServUO does not hot-reload the way Oxide does.
3. **Mono's `SocketException.Message` is NUL-padded.** A connect refusal came back with ~200 `\0`
bytes in the middle of the sentence, from a fixed-size OS buffer. `\0` is not whitespace, so
`Trim()` does not touch it and neither does a whitespace-only collapse — the log line looks like
it contains a huge run of spaces and no amount of trimming removes it. The flattener has to treat
`char.IsControl` as a separator too. It took `od -c` on the log to see this at all.
4. **`bootId` regenerated on every PLUGIN load, not every SERVER start.** A fresh `Guid` at `Init`
meant `oxide.reload RunicGateway` announced a brand-new boot — and §11.1's whole reconcile design
hangs off that value, so every reload would have asked core to sweep its entire resource ledger
for a world that never moved. It is now `Process.StartTime`, which is exact, identical on every
read, and changes when and only when the thing it names changes. **Verified by reloading the
plugin twice and watching the id hold** (`boot-20260915T194502Z`, matching the process).
5. **A four-connection SQLite pool over `:memory:` hands out four empty databases.** An in-memory
database is per *connection*, so the schema created on the first pooled connection is invisible
to the second. It presents as `no such table` from a random subset of queries. The sidecar now
caps the pool at one connection for an in-memory path — which is the only coherent reading of
`:memory:` and is what makes it usable at all. The ServUO sidecar never hit this because it only
ever opens a file.
### 13.4 Two things the kit's own template got wrong for this module
Both are feedback for phase 19, and both are the kit being right about the general case and specific
about the wrong detail.
- **`registration.test.js` reads one page by NAME** (`src/routes/public/Clan.jsx`) to check that
every declared slot is rendered somewhere. A module that declares no slots — as phase 1 does — dies
on `ENOENT` before reaching the loop that would have been empty. Generalised here to scan every
file under `src/routes`.
- **`test/_fakes.js` supplies `validator: {}`.** The template's routers never use express-validator,
so `{}` is enough for them; an admin router that builds validation chains at file scope cannot be
*required* with it. The fake now holds the real library, for the same reason it holds a real
express Router: a fake of either would only ever test the fake.
The kit was also **right in a way worth recording**: `noGameConnection.test.js`'s header predicts, in
so many words, that a module adding a sidecar client will see this check go red and tells the reader
to narrow it rather than delete it — naming `sidecarClient.js` as the file to allow. That is exactly
what happened, on the first run, and the fix was the one line the header names.
### 13.5 The player tier is thin on purpose
R14 puts the module on all three tiers from the start, and the loader holds `mounts` against what is
registered in both directions — so the declaration and the registration land together or not at all.
What the player tier will carry is the signed-in view of a server: the viewer's linked Steam
identity, their presence, their entitlements. None of that exists before phase 6. So the one route
there answers the server list on the authenticated tier, delegating to the **same model** the public
tier uses, so the two cannot drift while they are meant to be the same.
That is a real route rather than a placeholder: it is the address the app and the SPA will call, and
it starts answering correctly now rather than moving later.
### 13.6 Three timeouts in a row, and the ordering is load-bearing
```
sidecar RPC reply timeout (10s) < module client timeout (12s) < an action's budgetMs
```
Core classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot ask the action,
which is still awaiting a socket. So an action whose budget does not exceed the module's client
timeout can never report `retry: false`, and that branch is unreachable code. Phase 13's actions
must derive their budgets from `sidecarClient.TIMEOUT_MS` rather than writing a number beside it.
The first module this project shipped got this the wrong way round and retried a verb it had
explicitly refused, which is why all three values are now written down in one place
(`PROTOCOL.md` §4.4) instead of three.
### 13.7 The rig, as it stands after phase 1
The phase-0 rig plus the bridge. `RGProbe.cs` is still installed beside `RunicGateway.cs` and is
still useful — its `rg.checkperm` / `rg.zoneonme` commands are phase 7 and phase 12 instruments.
```
D:\rust\oxide\plugins\ Clans.cs Kits.cs PopupNotifications.cs ZoneManager.cs
RGProbe.cs RunicGateway.cs
rust-link-sidecar game 127.0.0.1:7799 · web 127.0.0.1:8090 · its own scratch config
website (edge) modules/rust/ installed as a directory; server row "main"
```
A dependency-free WebSocket RCON driver was rebuilt this phase (the phase-0 one lived in a scratchpad
that did not survive). `rcon.web 1`, port 28016 — the password is in `D:\rust\start.bat`. It is the
only way to reach `rg.link` without a console session, and phase 7 will need it again.
**The phase-0 ceiling still stands and still blocks phase 7:** no console session can observe a
permission gate, because every plugin's check short-circuits without a `BasePlayer`, and an admin
account bypasses most of them non-uniformly. A second, non-admin Steam account has to be arranged
before phase 7 — it is the one prerequisite this rig cannot satisfy on its own.
## 14. The Pterodactyl rig as built, 2026-09-15
R21's first server exists, made with the application key and driven with the client key. **Both
credentials work; neither can do the other's job.** What follows is what building it actually taught,
including one finding that changes R20's shape.
### 14.0 The rig
| | |
|---|---|
| Panel | `http://192.168.0.12` (no TLS — `https://` fails outright), node 1 `Main` |
| Servers | `rust-oxide` id **17** / **`e6758c06`**, and `rust-carbon` id **18** / **`87fb1f67`** (§14.5) |
| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` — both rigs, one egg |
| `FRAMEWORK` | `oxide` / `carbon` |
| Limits | 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it |
| Allocations | oxide 21000-21004, carbon 21005-21009 — game, query, RCON, Rust+, **and one held for the sidecar's `[web].bind`** |
| World | procedural, size 3000, seed 1234 |
| SFTP | `192.168.0.12:2022` |
The RCON passwords are generated 24-byte tokens rather than the old rig's `letmein`, kept out of this
document and out of the repo. §6 named that shape as the thing nothing published should copy; this is
the first rig where it was not copied.
**A Rust server install is about 6 GB, not the ~20 GB this plan assumed** when it worried about node
capacity — measured at 5,894 MB with the game installed and the world generating. Two rigs are
comfortable on a 128 GB node, and the 25600 MB limit is generous rather than tight.
### 14.1 The two keys, and what each one is actually for
Confirmed by use rather than by reading:
| | Application (`ptla_`) | Client (`ptlc_`) |
|---|---|---|
| Create / configure a server, assign allocations | **yes** | no |
| List, read, power, console, **files** | no (`403`) | **yes** |
| Write or import an egg | **no** — `/api/application/eggs` 404s; eggs are an admin-UI or `php artisan` operation | no |
So the full loop needs both, and **a published egg is a JSON file a human imports** — which is also
exactly how an operator will consume ours, so it is a constraint worth designing into rather than
around.
**File operations are refused during install** with `409 ServerStateConflictException` —
*"this server has not yet completed its installation process"*. Anything that pushes files has to
wait for `is_installing: false`, not merely for the server to exist.
### 14.2 The correction: there was never a panel bug
An earlier pass through this section recorded that the client key "authenticates and then lists zero
servers", and reasoned from there to a second claim — that *includes are broken on this panel*,
because `/api/application/servers?include=user` returned an empty list where the same route without
the include had returned six.
**Both were wrong, and wrong the same way.** The servers were being deleted while the probing
happened, so two calls minutes apart read two different worlds. Once a server existed, every one of
those calls answered correctly on the first attempt — the client list, the single-server route, and
`include=user`.
The reusable part is not "check twice". It is that **a differential diagnosis across two API calls
silently assumes the state did not move between them**, and on a live panel somebody else is also
holding the controls.
### 14.2b The upload loop, proven with the real plugin
Not a hello-world: phase 1's actual `RunicGateway.cs` (27,642 bytes, 709 lines) was pushed straight
from the working tree with the client key, and it came back byte-identical on read.
Three things that worked and were not certain to:
- **`files/write` creates missing parents.** `/oxide/plugins/` did not exist — the framework is laid
down at boot (§14.3), and the server had never been started — and the write created the whole path.
- **A plugin placed before Oxide exists survives Oxide arriving.** The entrypoint's `unzip -o` over
`oxide/` left the file untouched, so the push does not have to wait for a first boot.
- **It compiled and loaded on Linux**, which no previous phase had ever established. Every prior
finding came from Windows and Mono:
```
02:19 [Info] RunicGateway was compiled successfully in 0ms
02:19 [Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
02:19 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
02:19 [Info] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly
```
That last line is phase 1's no-stall contract holding on a second platform: no sidecar exists on
this host yet, the plugin says so once and keeps the game running.
**Read the console without a websocket.** Pterodactyl streams console over a websocket, which is
awkward to drive from a script — but `wrapper.js` also writes `latest.log`, and Oxide writes
`oxide/logs/oxide_<date>.txt`. Both are plain reads through `files/contents`, which is how every log
line quoted in this section was obtained. Worth knowing before anyone writes a websocket client.
### 14.2c The tier-2 loop, end to end
R21's middle tier is the one that has to be pleasant to use, so it was run rather than described.
One pass: patch the working-tree source so the change is visible in the game console, push, reload
through the client API's `command` endpoint, read Oxide's log back, then restore.
```
patched source: True
push -> HTTP 204
oxide.reload -> HTTP 204
02:27 [Info] RunicGateway was compiled successfully in 3392ms
02:27 [Info] Unloaded plugin Runic Gateway v0.1.0 by RunicGateway
02:27 [Info] [Runic Gateway] protocol 1 [PTERODACTYL-PUSH-PROOF], serverId 'main', sidecar 127.0.0.1:7799
02:27 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
restored source and re-pushed -> 204
```
**Roughly ten seconds from a saved edit to a reloaded plugin**, against a running server with a
generated world, without touching the panel UI. That is the loop `deploy.ps1` gives us for ServUO,
and it is the thing that makes the panel a workable rig rather than only a deployment target.
Four details worth carrying into the push script:
- **Reload is `POST /command`, not a file operation**, and it answers `204` whether or not the plugin
actually came back. The proof has to be read out of `oxide/logs/` afterwards — the same shape R18's
auto-rollback needs, and an early rehearsal of it.
- **The unload/load pair straddles the plugin's own `Init` log line.** `Unloaded` is printed, then the
new instance's startup line, then `Loaded`. A script that waits for `Loaded` before reading has
already passed the line it wanted.
- **Oxide's compiler idles out and restarts.** The boot compile was `0ms`; the reload compile was
`3392ms` because `Shutting down compiler because idle shutdown` had happened in between. A timeout
tuned against a warm compiler will be wrong on the first reload after a quiet period.
- **Restore the working tree and re-push it.** A test that leaves a marker in the source is a test
that ships a marker. Both were put back and verified byte-identical against the server copy.
### 14.3 The image installs the framework on **every boot**, and neither version is pinnable
`ghcr.io/pterodactyl/games:rust`'s entrypoint is where `FRAMEWORK` is consumed — **not** the egg's
install script, which knows nothing about it. On every single start, before the game runs, it:
- runs `steamcmd +app_update 258550` unless `AUTO_UPDATE=0`;
- for `carbon`, downloads
`CarbonCommunity/Carbon.Core/releases/download/**production_build**/Carbon.Linux.Release.tar.gz`;
- for `oxide`, downloads `OxideMod/Oxide.Rust/releases/**latest**/Oxide.Rust-linux.zip`.
**Both are moving targets, fetched fresh at every restart.** CARBON.md §8 predicted this for Carbon
from its rolling release tags; the egg makes it true of *Oxide as well*, because `latest` is the same
kind of promise. The consequence is sharper than "the rig may drift":
> **A restart is a framework upgrade.** Two runs of the same test on the same server, minutes apart,
> are not guaranteed to be running the same framework build — and nothing in the panel says so.
That reaches three places. **R4's `doctor`**: the weaker "current enough" claim is not Carbon-specific
after all; under the egg neither framework has a pinned version to check. **§6's wipe-cadence risk**:
the re-verify step is per *restart*, not per wipe. And **R20 itself**: if the egg is our deliverable,
whether it should pin the framework at all is a decision, not an oversight — the upstream egg's
answer is "always newest", which is right for an operator on wipe day and wrong for a test rig
trying to reproduce a finding.
### 14.4 **The trap that changes R20: the startup string is not a safe place to launch the sidecar**
R20 says the startup command becomes "a small wrapper that launches `rust-link-sidecar` and then
`RustDedicated`". The mechanism allows it and the ordering makes it wrong.
`wrapper.js` runs the startup string through `child_process.exec`, which is `/bin/sh -c` — so
`./rust-link-sidecar & ./RustDedicated …` is syntactically fine. **But for Carbon the entrypoint
prepends to the whole string:**
```bash
MODIFIED_STARTUP="LD_PRELOAD=$(pwd)/libdoorstop.so ${MODIFIED_STARTUP}"
```
So a startup beginning with our sidecar becomes:
```bash
LD_PRELOAD=…/libdoorstop.so ./rust-link-sidecar & ./RustDedicated …
```
**The preload lands on the sidecar and not on the game.** Carbon loads through Doorstop rather than
through a patched `Assembly-CSharp.dll`, so the result is a server that starts cleanly, reports no
error, and **is not modded** — no plugins, no hooks, and a bridge that connects to a game it can
never hear from. It is the exact silent-success failure §6 keeps cataloguing, and it would only ever
appear on the Carbon half.
Two further consequences of the same handoff:
- **`quit` SIGTERMs the shell, not the sidecar.** `wrapper.js` kills `gameProcess`, which is the `sh`
running the startup string; a backgrounded sidecar is not its child in the way that reaches. R20
already required "stop means stop the game" — this is the mechanism by which it would fail, and it
leaves an orphan holding port 21004 against the next start.
- **Doorstop also confirms R21's clean-install rule from a second direction.** Switching `FRAMEWORK`
on an existing install does not undo the other framework: Oxide's patched DLL stays on disk while
Carbon preloads over it. The migration argument was the soft reason for a fresh Carbon rig; this is
the hard one.
**So R20 needs a decision it did not know it needed:** the sidecar is launched by something other
than the startup string — our own image or entrypoint layered on the upstream one — or the startup
string is composed so that whatever the entrypoint prepends still lands on `RustDedicated`. The first
is more work and survives upstream changing its entrypoint; the second is free and depends on a line
in somebody else's repository. Raised rather than settled.
### 14.5 The Carbon rig, and R19 proven
`rust-carbon` — id **18**, identifier **`87fb1f67`**, same egg, same world (procedural, 3000, seed
1234), same limits, allocations **21005-21009 with 21009 held for the sidecar**, `FRAMEWORK=carbon`.
**A clean install, never a converted one**, per R21: install 122s, boot 543s, running.
**R19 is proven.** The byte-identical `RunicGateway.cs` that runs on the Oxide rig — no `#if CARBON`
anywhere in it, nothing conditional at all — loaded on Carbon **2.0.259.0** and behaved the same:
```
[INFO] Carbon 2.0.259.0 [2026.09.03.0] 21063e8 on Linux
[INFO] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
[INFO] Loaded plugin Runic Gateway v0.1.0 by RunicGateway [2367ms]
[INFO] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly [RunicGateway Link|26]
```
That last line is the no-stall contract holding on its **third** platform now — Windows/Mono,
Linux/Oxide, Linux/Carbon — from one source file. [`CARBON.md`](CARBON.md) §10 is the full scorecard;
the parts that change decisions are below.
**R18's amendment is confirmed the best way it could have been.** The plugin's own config, written by
the same Oxide-compat API on both rigs, landed at `/oxide/config/RunicGateway.json` on one and
**`/carbon/configs/RunicGateway.json`** on the other. D3 put the plugin's config inside R18's editor;
had that editor used a literal `oxide/config/`, **it would not have found its own plugin's config on
half of all installs.** No test would have caught it; only two rigs would.
**And one claim was refuted — the one with the sharpest consequence.** `CARBON.md` had said Oxide
stores permissions as JSON and Carbon as Protobuf or SQLite, offering the difference as the reason
not to read the file. Both rigs say otherwise:
| | Oxide rig | Carbon rig |
|---|---|---|
| Path | `oxide/data/oxide.users.data` | `carbon/data/`**`oxide.users.data`** |
| First bytes | `0a 16 0a 07 64 65 66 61 75 6c 74` | `0a 17 0a 07 64 65 66 61 75 6c 74` |
| Format | Protobuf | Protobuf |
| Default groups | `default`, `admin` | `default`, `admin`, **`moderator`** |
**Same binary format, same filenames, different directory** — and Carbon writes *its* data into files
named after Oxide. **This makes R2's API-only rule more important, not less.** A file reader would
have worked on both rigs today and broken silently for the one operator who ran `c.migrate_perms_sql`
— no error, no version marker, just a site reporting drift against a store nobody is writing any
more. The rule survives; the reasoning behind it was wrong and is now right.
Two things nobody had thought to claim, found by looking:
- **Carbon auto-creates a third default group, `moderator`**, auto-granted by auth level alongside
`default` and `admin`. R2 pushes its *full* set on connect, so it has to tolerate a group the
framework will recreate the moment it is deleted — otherwise the site reports drift for ever.
- **`c.plugins` reports per-plugin `hook fires`, `hook time`, `hook memory`, `hook lag` and
`hook exceptions`** — most of §6's "log which expected hooks have fired" mechanism, free, and only
on Carbon. Useful when debugging on Carbon; **not a substitute** for the plugin's own counter, which
must work on both.
**A warning about how a wrong console command fails here.** Pterodactyl's `command` endpoint returns
`204` whether or not anything happened, and Carbon prints nothing for an unknown command. So
`oxide.plugins` on Carbon — which is simply not a command — is indistinguishable from success at the
API. Anything driving the console has to read a log to know, which is the same conclusion §14.2c
reached about `oxide.reload` and the same shape R18's rollback needs.
## 15. Phase 2 as built — packaging and release, 2026-09-16
The first phase with no game server in it, and the one that turns a directory somebody copied into a
thing an operator can install. [`Module-Rust#2`][mr] is the whole of it.
**The starting position was worse than the phase row implied.** Phase 1 built five guards and ran
every one of them by hand: the repository had no `.gitea/workflows/` at all. So nothing gated the
branch that gets released, and there was no way to release it. This phase adds both halves, and the
org lead widened it to include the gate rather than only the release.
### 15.0 The three decisions this phase needed
- **D4 — the full CI suite, not only `release.yml`.** The phase row names the release; the repo had
no gate at all. Both ship, and the frozen-manifest job ships with them rather than waiting for a
later phase — see §15.2 for what it found on its first run.
- **D5 — the bundle carries no `node_modules`, and the emptiness is asserted rather than assumed.**
The shipped half declares no runtime dependencies: everything it needs arrives on `ctx`. So the
release runs no `npm ci` and packs no dependency tree. The whole value of that decision is that
*the day it stops being true is a loud day*, so `checkBundle.js` fails the PR that adds a
`dependencies` entry without also teaching the release to install and pack it. Module-uo ships
`ws` and does the opposite; this is a different answer to the same question, not a divergence
from a rule.
- **D6 — no SonarQube for the three Rust repos yet.** Phase 2 is packaging; scanning is quality
tooling and phase 19 is already the sweep where `CLAUDE.md`'s project-key table gets updated.
Three half-populated projects while the repos are skeletons buy nothing.
### 15.1 What is in a release, and what decides its number
A release is **not source**. It is the directory core's loader expects at `modules/rust/`, already
assembled — the prebuilt client chunk, the schema fragment and the OpenAPI fragment, packed as they
will be unpacked — because an operator never builds anything (`MODULE_SYSTEM.md` §1.14).
| Asset | What it is |
|---|---|
| `module-rust-<version>.tar.gz` | the assembled directory, one top-level entry, no `node_modules` |
| `module-rust-<version>.json` | the install manifest: id, name, version, `coreApi`, url, size, **`sha256`** |
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
The version is **derived**, using the engine `link`, `installer` and `Module-uo` already run:
conventional-commit subjects since the newest `v*` tag decide major/minor/patch, nothing releasable
cuts no release, and `module.json`'s version survives as a **floor** rather than as a record. The
number that ships is the **tag**, and CI stamps it into the bundle's own `module.json` at assembly
time — which is why the release also asserts that the assembled `module.json` carries the version
being released, since a bundle that still declared the floor would install under a number that is
not the one it came from.
Module-uo is the argument for deriving rather than declaring: it released only on a hand-edited
version line, and between 2026-08-12 and 2026-08-19 that cost it *every* bundle, because nine phases
of work landed without anyone touching that line. `workflow_dispatch` survives as the backdoor for
the case the rules cannot reach — a widened `coreApi`, a new mount, a capability, with no releasable
code behind it.
**Two failure modes are guarded before anything is built.** The credential check runs first, so a
repository without `REGISTRY_TOKEN` fails with a named error instead of pushing a tag and then 401ing
on the release API — the state `servuo-plugins` got stuck in on its own first release. And a tag that
exists with **no release behind it** is deliberately not treated as "nothing to do": that is the
signature of the same half-failure, and standing down on the tag alone would make it permanent.
### 15.2 The frozen manifest answers the question phase 1 had to take on trust
`§13`'s registration comment says `/rust` "collides with nothing on any of the three tiers, checked
against core's mount tables rather than assumed" — and then names the limit of that check: **core
answers several public routes mounted at the tier root rather than under a prefix**, `/status` and
`/version` among them, which the loader's own collision probe cannot see.
The `frozen-manifest` job is the thing that can. It clones core at the sha pinned in
`ci/core-ref.json`, generates core's route table **without** this module and then **with** it, and
takes the difference. That difference is what the module serves, and it is checked three ways: it
must match the committed `routes.manifest.json`, every route in it must have an operation in
`swagger-fragment.json` *and* every operation must be a route, and — the half that matters most —
**no core route may have been removed or changed**. A module whose mount displaced a core route
cannot show up as an addition, because the URL is unchanged; only the diff sees it.
Run locally against the pinned ref before it was committed, and again in CI against a fresh clone,
both agreeing:
```
route manifest up to date (280 routes)
wrote routes.manifest.json (282 public + 4 internal)
routes.manifest.json is current — 6 routes, all documented
```
Six routes, all documented, nothing of core's moved. **`/rust` is now free by proof rather than by
reading**, and it stays that way on every pull request.
**The pin starts on `main`, unlike Module-uo's.** That repo spent the whole Event System window
pinned to `edge`, because it depended on contract members that had not reached `main` yet. This
module needs `MODULE_API` 1.10.0, which the Event System cutover already put there, so
`ci/core-ref.json` names `efa9db7` on `main` and should stay on `main` until this module comes to
depend on something unreleased.
### 15.3 One declaration, two readers — and it was verified by breaking it
`ci/bundle.json` is an **include** list, never an exclude list: an exclude list ships whatever it
forgot, and the day somebody adds `server/tools/` with a scratch credential in it, an exclude list
packs it and nobody finds out. The cost of that choice is the opposite failure — a new directory
silently drops *out* of every release — which is exactly what happened to Module-uo between v0.3.0
and v1.0.0, where `server/commands/` arrived in a cutover, the list did not learn about it, and the
module installed cleanly and then died at the register stage on the operator's box.
Nothing caught it there because the PR checks copy the **whole repo** into core: they only ever
exercised a tree that had the file. **The subset exists only in the release.** So the list has two
readers — `release.yml`, which packs from it, and `server/scripts/checkBundle.js`, which asks on
every PR whether it still covers everything `server/index.js` can reach.
It reaches requires written **inside `register()`**, which is not a detail: this module's entry point
requires its routers inside the function on purpose, because `core.init(ctx)` has to run before
anything under `router/` is required. A check that only saw file-scope requires would have missed
every router the module has.
Verified the only way a check is worth anything — by breaking it. Dropping `"model"` from the list:
```
ci/bundle.json does not ship everything server/index.js reaches.
server/model (2 files reachable)
Add "model" to ci/bundle.json's server[].
```
It names the exact edit, in the units the list is written in.
### 15.4 The release, as published
`v0.1.0`, cut by the first push to `main`, with no tag before it — so the engine took the first-run
branch and shipped what `module.json` declared. Three assets:
| Asset | Size |
|---|---|
| `module-rust-0.1.0.tar.gz` | 41,310 bytes |
| `module-rust-0.1.0.json` | 355 bytes |
| `SHA256SUMS` | 91 bytes |
Downloaded and hashed independently of CI, the artifact is
`7296c76b988c6191840a1dc4ed1a77d96ad35e8c26f9b6b55e88fe4c10878b32` — byte-for-byte the `sha256` the
manifest declares.
**`REGISTRY_TOKEN` was already configured on the repository**, which was the one prerequisite this
work could not verify for itself: the bot identity can read neither repository nor organisation
Actions secrets (`user should be the owner of the repo`). The credential check exists precisely
because the answer was unknowable from here, and the first release ran clean through it.
### 15.5 The acceptance criterion, walked
> *An operator installs the empty module from Admin → Modules and it reaches `started`.*
**Met.** Walked as an operator would, against the local core on `edge`, with the phase-1 state
deliberately torn down first — the hand-copied `modules/rust/` directory moved aside and its
`installed_modules` row deleted, so this was a first install and not an upgrade. Before it,
`GET /api/v1/public/rust/servers` answered **404**.
1. Admin → Modules, with no `rust` row and `gitea.whitlocktech.com` on the allowlist.
2. Pasted the release's `module-rust-0.1.0.json` URL and pressed Install.
→ *"Installed Rust v0.1.0. Restart to load it."*, and a row reading **Restart to start —
installed, it mounts when the server next starts**, carrying the manifest URL and
`sha256 7296c76b988c…`.
3. Restarted.
→ `registered module "rust" v0.1.0`, `schema ensured for module "rust" {"statements":2}`,
`[rust:boot] booted {"refreshMs":30000}`, `module "rust" started`.
The row is now `state = started` with `started_at` set, the source URL and the released hash — where
phase 1's row had `source` and `sha256` **null**, because a directory somebody copied has no
provenance to record. The screen reads **Running — mounted and serving**.
**What the volume received is exactly the include list and nothing else: twenty files.** No tests, no
`server/scripts/`, no `ci/`, no `client/src/`, no `node_modules`, and no dotfiles. `client/dist/entry.js`
is the chunk CI built.
And the three checks that prove it is *serving* rather than merely loaded:
| | |
|---|---|
| `GET /api/v1/public/rust/servers` | **200**, answering with the rig's `main` server |
| `GET /api/v1/player/rust/servers` · `GET /api/v1/admin/rust/servers` | **401** — the tier gates are on, not bypassed |
| `/api/docs.json` | carries all **five** `/rust` paths: the fragment merged into core's own spec |
**Two things this walk is honest about.** The first is that the *restart* was done from the shell
rather than with the screen's own **Restart the server** button: that button runs the same graceful
shutdown a `SIGTERM` does and relies on a supervisor to bring the process back, which the shipped
`docker-compose.yml` provides and a bare `npm start` does not. The button was not exercised, and it
is core's, not this module's. The second is that the module's two tables were left in place when the
row was deleted, so the rig's `main` server row survived the reinstall — the schema fragment replayed
on boot (`statements: 2`) exactly as R12 requires of a fragment that runs every time.
**The one operational finding, and it is about the rig rather than the module.** Docker Desktop's
Linux engine was found dead — its WSL distribution stopped, the `uomm-db` container exited, and
`docker` answered every call with `500 Internal Server Error` rather than anything naming the cause.
Restarting Docker Desktop and the container fixed it. Worth writing down because the failure presents
as the *website* being broken (`ECONNREFUSED` to a database that is simply not there), and because
`CLAUDE.md` points every smoketest at that one container.
## 16. Phase 3 as built — the read path, 2026-09-16
The first phase that had to be true on two mod frameworks, and the first with a
catalogue rather than a message. Four repositories moved: the spec here, the plugin, the sidecar,
and the module.
**Status: the bridge half is done and proven; two proofs are queued on the org lead.** What the
plugin sends and what the sidecar does with it are built, tested and exercised against live Oxide
and Carbon servers. The player-facing half of the catalogue — deaths, chat, gathering, sessions —
cannot fire without somebody holding a mouse, and is written down as a walk to run rather than
guessed at: [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). §16.7 lists everything still open.
### 16.0 The four decisions this phase needed
- **D7 — the widest first hook wave.** The options ran from "presence, deaths and the wipe" to
"everything read-only worth having", and the widest was chosen: fifteen hooks, including the
moderation set that carries IP addresses and player reports. The consequence is real and is
designed around rather than deferred — those frames arrive **nine phases before** the visibility
framework phase 14 builds, so the classification and its default-deny allowlist ship now (§16.4).
- **D8 — the plugin derives `wipeId`.** `PROTOCOL.md` §3.2 had reserved that for the website. By
protocol 2 three components store rows that need it and only one of them can read the value, so
the reversal is written into §8.2 rather than left as a contradiction.
- **D9 — the live feed is a cursor, and D5 stands.** Core runs Node 20, where a global `WebSocket`
is still behind a flag, so a socket means taking `ws` — against a release that asserts it ships no
runtime dependencies. The deciding argument was the other one: **a socket needs a cursor anyway**
for what it missed while the module was restarting, and the catch-up path is the one that must be
right. One mechanism exercised every five seconds beats two where the second only runs after an
outage nobody planned.
- **D10 — rollups permanent, raw bounded.** The website keeps per-player-per-wipe totals for ever
and a 30-day window of raw events; the sidecar keeps 14 days and prunes hourly. R12's "a wipe does
not erase a player's history" is met by the totals, which is the row an operator actually reads.
- **D11 — CI for both bridge repositories**, which had none at all. Phase 2 found that hole in
Module-Rust; it was still open in the two repositories that ship the half running inside somebody
else's game server.
### 16.1 `type` is the whole of protocol 2 in the sidecar
Protocol 1 routed on `kind`, in a `match` that needed a new arm per addition. Protocol 2 adds
**`type`** — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing
else. Ten new event kinds are now zero change in Rust-Link, which is the property that matters when
the thing growing fastest is the catalogue.
A frame whose `type` this build does not know is **dropped and counted**, never guessed at.
Defaulting an absent one to `event` would file a *board* as history — the presence board appended a
few thousand times, which nothing reports and nobody notices until they wonder why the database is
large.
**It caught a real mismatch three seconds after it first ran**, which was not planned: a protocol 1
plugin was still live on the retired workstation rig, dialled the new sidecar, and its `server.hello`
went straight into the counter. The game link has no version handshake by design (§2), so
`untyped_frames` on `/health` is the only place that failure is visible — and the symptom without it
is a website showing nothing while the game is plainly up.
### 16.2 Two defects a live server found, and neither could have been found anywhere else
**The wipe id was null for every real session.** `Init` runs *before* the save is loaded, so
`SaveRestore.SaveCreatedTime` is not yet meaningful there, and the id resolved at load time stayed
null for the life of the process — every frame shipping without the field R12 splits history on.
It was invisible for the reason such things usually are: a **hot-reloaded** plugin reads an
already-loaded world and gets the right answer every time. Every development iteration on the
workstation rig was a hot reload. It took a server that *booted* with the plugin installed — which
is every real one — to show `wipeId=none` beside a save sitting on disk. Now resolved again at
`OnServerInitialized`, and lazily while still unknown.
**`Unload` blocked the game's main thread for two seconds.** Carbon reported it exactly:
`hook 'Unload' took longer than 100ms [2002ms]`, next to `link thread did not stop cleanly`. That is
phase 1's stall arriving by a different road — the link thread sits in a blocking
`TcpClient.Connect`, which has no timeout of its own and cannot be woken, and `Unload` joins it from
the main thread.
The reason two phases missed it is worth keeping: **a host that refuses answers instantly, and a
host that drops does not answer at all.** Every loopback test is the first kind. A firewalled
address, a typo, a machine that is off are all the second, and the deployment this phase was being
tested through happened to be one. The connect is now bounded and waits on a stop handle of its own
— it cannot share `Wake`, which also means "the queue has something in it" and is signalled by every
hook that fires. After the fix the same reload logs no slow-hook warning and no stranded thread.
### 16.3 The aggregate, and the rule it generalises
`OnDispenserGather` fires on **every swing at a tree**. A frame per swing would make the bridge the
most expensive thing on a busy server, and nobody wants a killfeed of chickens either, so gathering
and NPC kills are counted in the plugin and flushed once a minute as one `player.tally` frame.
The rule: **if a hook can fire more than once a second per player, it is a counter, not an event.**
R17's warning about chatty zone transitions is the same rule arriving early.
A tally is a **delta, not a running total** — what happened since the last flush — so the consumer
sums rather than diffs, and a dropped frame costs one interval instead of corrupting the series. The
outbound queue is drop-oldest by design, so frames are genuinely allowed to go missing; a running
total over a lossy link is a number that is quietly wrong for ever.
One honest limitation, corrected in the code rather than in the comment that first claimed
otherwise: **a plugin reload loses up to a minute of one player's tally.** `Unload` enqueues the
flush, but the writer stops on the same flag and the queue is cleared after the join. Draining it
first would mean waiting on a socket from the main thread — the stall §16.2 just removed — so the
loss is taken deliberately. A real shutdown flushes at `OnServerShutdown`, and a player leaving
flushes at their disconnect.
### 16.4 The boundary is enforced by the side that serves
The widest hook wave brings IP addresses (`CanUserLogin`, `OnUserApproved`, `OnUserBanned`), one
player's report about another, and the grid reference of somebody's base — nine phases before the
visibility framework §11.2 costed. So the classification ships with the catalogue.
**It is not a field on the wire.** The plugin could have stamped a class on every frame; it
deliberately does not. A boundary declared by the *sender* is one a compromised — or merely
out-of-date — game host can widen. The website's own shard fan-out works the same way: a public
stream with an allowlist of kinds and an admin stream that adds the rest, and what makes it
trustworthy is that the decision lives on the serving side.
So `module-rust/server/catalogue.js` holds it, **default-deny**: a kind this build has never heard
of is not public. That is the shape of the mistake it prevents — the next protocol version adds a
kind, the module stores it happily, and a deny-list filter would publish it the day it first
arrived, before anybody decided whether it should be. A test holds the list against §8.4's table, so
adding a kind to the protocol without classifying it fails a build.
### 16.5 A login denial is not a hook, and §10 says it is
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
cannot work: it fires on **every** connection attempt, and the only way to learn of a denial from it
is to *be* the denier — which §8.7 forbids, structurally, by declaring every read-path hook `void` so
it cannot answer. uMod publishes no `OnUserRejected`.
What the game can tell us is two facts: an attempt, and an approval. Protocol 2 emits both, and a
denial is **the absence of an approval** — a deferred read, phase 10's to make. The trigger survives;
its source changes. (The same shape the engagement workstream hit at its own phase 10, which is
either a coincidence or a property of login paths.)
### 16.6 What was proven, and how
| Claim | How | Result |
|---|---|---|
| The read path compiles and loads on **Oxide** | live server, protocol 2, 15 hooks bound | ✅ |
| …and on **Carbon** 2.0.259.0, from the **byte-identical file** | the Pterodactyl rig, config read from `carbon/configs/` | ✅ |
| Every frame carries `type`, `serverId`, `wipeId` | `server.hello` and `players.online` read back off the sidecar | ✅ |
| A wipe id derived from the save, changing only with the save | `rg.link` reports `w-20260915T195817Z` against `saveCreatedAt 2026-09-15T19:58:17Z` | ✅ |
| **A restarted sidecar is fully populated within one connection** | store deleted, process restarted: both boards present **0.3 s** after the listener bound, and **zero** events in history | ✅ |
| Boards are not replayed as history | the same walk: `/events` returned 0 rows while `/boards` returned 2 | ✅ |
| Moderation frames reach the sidecar whole | `banid` / `unban` over RCON | ✅ |
| A protocol mismatch is counted, not mis-filed | a live protocol 1 plugin against the protocol 2 sidecar | ✅ |
| `rg.hooks` answers on both frameworks | Oxide and Carbon, identical output shape | ✅ |
| 44 sidecar tests, 95 module server tests, 20 client tests, every guard | locally, and now in CI on both repos | ✅ |
| The module's route manifest against a **real core** at the pinned ref | 10 routes, all documented, none of core's moved | ✅ |
| **The module ingests a live game** | the working tree installed into a running core, pointed at the workstation sidecar: `cursor started at the feed tail`, then a console `banid`/`unban` arrived as two `rust_events` rows with their wipe id, cursor advanced | ✅ |
| **The allowlist holds against real rows** | with both ban events in the table, `GET /public/rust/servers/:id/events` answered `{"events":[]}` — **and answered the same when asked for `player.banned` by name** | ✅ |
| A wipe row is created by being mentioned | `w-20260915T195817Z` appeared in `rust_wipes` from the first frame carrying it, with no "a wipe started" call anywhere | ✅ |
**One finding about the rigs rather than the code:** the panel rigs cannot reach a sidecar running on
the workstation, because Windows Firewall holds two program-scoped **Block** rules for
`rust-link-sidecar.exe` — created by a dismissed prompt at some point — and a program-level block
beats any port-level allow. Removing them needs elevation. It is a rig problem only: the shipped
design puts the sidecar on the game host's own loopback (D2, R20), where it is the deployment that
never needs a rule at all.
The local workstation rig, which does reach its sidecar on loopback, is what proved everything in the
table above that needs a live socket. **`D:\rust` is therefore not as retired as R21 assumed** — it
survives as the fast loop (a saved file is a reloaded plugin in about ten seconds, against nine
minutes of world generation on the panel), and the panel rigs are what answer "on both frameworks".
### 16.7 What is still open, and who it is waiting on
Three things, all of them measurements rather than decisions, and all of them the org lead's to run:
1. **The player walk** — [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). Ten minutes on a rig
with a mouse, and it closes `OnPlayerDeath`, `OnPlayerChat`, `OnDispenserGather`,
`OnPlayerRespawned`, `OnEntityDeath`-by-a-player, and `sessionSec`. The document says what each
step should produce, so it can be run without anybody watching the output live.
2. **The wipe walk.** `OnNewSave` fires when a server starts with no save — the panel rigs wipe
through the egg's own `REMOVE_FILES`, so this is the rig's own mechanism rather than a special
test. What it proves is the second half of the acceptance criterion: that the old wipe's rows are
still queryable by `?wipe=` afterwards.
3. **The Carbon socket leg.** Everything up to the socket is proven on Carbon; what is not is frames
actually arriving over a live link, which is one elevated firewall command away
(`Remove-NetFirewallRule -DisplayName "rust-link-sidecar.exe"`, then an allow for the rig).
Until 1 and 2 are run, the honest statement of this phase is: **the transport, the envelope, the
boards and the classification are proven on both frameworks; the player half of the catalogue is
built, reviewed against the hook documentation, and unmeasured.** That is written here rather than
in a commit message because it is the kind of thing a later phase will want to know it inherited.
### 16.7b The ingest walk, and the trap it walked into
The module half was exercised against the live rig rather than only against its own tests: the
working tree installed into a running core, pointed at the workstation sidecar with a real Rust
server behind it. It logged `cursor started at the feed tail {at: 1}` — the fresh-install path,
starting at the end rather than replaying — and a console `banid`/`unban` pair then arrived as two
`rust_events` rows carrying the wipe id, with the cursor advancing to 3 and `events_seen` at 2.
Then the boundary, on real rows rather than fixtures: with both ban events sitting in the table, the
public events route answered `{"events":[]}`, **and answered the same when asked for
`player.banned` by name**. That is the difference between a filter and a refusal — the kinds are, as
far as a public caller is concerned, not there.
**The trap, and it is the same one the Pterodactyl work recorded.** The server-state row read as
`online=1` with `hostname=NULL` and `protocol=NULL`, which is a shape no code path writes. The cause
was two cores sharing one database: a second instance left running from earlier in the day still
holds the **phase 2 release** in memory, speaks protocol 1, is refused `409` by a protocol 2 sidecar,
and writes the row back as unreachable every thirty seconds. Nothing was wrong with either of them.
What made it *look* like a defect was the measurement: two `SELECT`s in two round trips, assuming
the state did not move between them. A single atomic read caught the coherent row a moment later —
`online: 1, protocol: 2, hostname: Test Server` — alternating with the other writer's.
**A differential diagnosis across two calls is only valid if nothing else holds the controls**, and
on a shared rig something usually does.
### 16.8 Smaller things worth keeping
- **`rg.hooks` collides with `RGProbe`**, the phase-0 rig plugin, which registered the same console
command first. Oxide warns and the last loaded wins, which happens to be the bridge. Left alone:
`RGProbe` is rig scaffolding that never ships, and renaming the shipping command to avoid a
test tool would be the wrong way round.
- **The IP a console ban reports is the literal string `"0"`**, not an address and not a null, when
the banned id is offline. Observed, not guessed. The plugin omits the field instead of forwarding
it — a column full of `"0"` survives every is-it-missing test a reader writes and then fails
whatever parses it.
- **`--print-config` reports the *effective* configuration and writes the *file* one.** Environment
variables override the file (R22 depends on that), and the written file never contains them. Not a
bug, but the two are not the same document and an installer reading one should not assume the
other.
- **A `cargo clippy` run does not produce a binary.** Two rig readings disagreed with the source
because the sidecar under test was an older `cargo build`; `clippy` and `test` compile without
writing one. Rebuild before believing a rig.
## 17. Phase 4 as built — the first pages, 2026-09-16
One repository, and the first phase whose whole deliverable is something a visitor looks at. It
consumes exactly the routes phase 3 built and adds one of its own; nothing here talks to a game
server, which is the point of the criterion it was written against: **the site renders the last
thing each server said while every server is off.**
**Status: criterion met, and met the hard way.** The pages were walked in a browser against a live
rig — a real Rust server behind the real sidecar for `main`, and a second, deliberately unreachable
server for everything the record holds. That walk found four defects, two of them in code phase 3
had already shipped, and all four are fixed here (§17.2).
### 17.0 The four decisions this phase needed
- **D12 — `/rust` is the server list.** Phase 1 registered it at `/rust/servers` and left the
module's own namespace root answering core's CMS catch-all. R8 calls the list "the landing page",
so it is registered with an **empty path** — core renders that as `/rust` — and the detail page
hangs beneath it at `/rust/servers/:id`. One canonical address, and it is the module's name.
- **D13 — one page with in-page tabs**, not four routes. `/rust/servers/:id` carries a header and
four panels (feed, leaderboard, online, wipes). A tab strip is not in the shared UI kit, so the
module bundles its own — which is the kit working as designed rather than a gap in it (§3.4 is a
closed list of nine members, and everything above them is the module's).
- **D14 — poll while the tab is visible.** The feed and the presence list re-fetch every twenty
seconds, paused by the Page Visibility API and refreshed the instant a viewer comes back. The
leaderboard and the wipe list load once: a table that re-sorts itself under the reader's cursor is
worse than one four minutes old. Server-Sent Events were considered and are not this phase — core
has a fan-out, but the module registers no stream, and a cursor-driven one is phase scope.
- **D15 — the footer slot carries a live count**, not a static link: `2 servers · 42 online`, linking
to `/rust`. The cost is stated rather than assumed — see §17.4.
### 17.1 What is on the pages
`/rust` is the list: name, map, size, when it was wiped, when it last reported, and the player count
or `Offline`. The whole row is the link.
`/rust/servers/:id` is the server. The header is what a Rust player asks first — map, world size,
seed, wipe date — with live status beside it and a wipe selector that applies to the whole page.
Then four tabs:
| Tab | Reads | Refresh |
|---|---|---|
| Feed | `…/events`, with a filter that maps to the `kind` parameter | 20s, visibility-gated |
| Leaderboard | `…/leaderboard`, sortable, per wipe or all-time | on mount |
| Online | `…/online` — the presence **board**, not counted transitions | 20s, visibility-gated |
| Wipes | `…/wipes`; picking one filters the feed | on mount |
**Everything selectable is in the URL** — tab, filter, wipe, sort. That costs a little ceremony in
the page and buys the thing a community site is for: *"last wipe's leaderboard on Main"* is a link,
the back button undoes a click rather than leaving the page, and a refresh lands where the reader
was. `wipe=current` is a word rather than an id on purpose, so a shared link stays about now.
**The feed renders parts, not sentences.** `lib/feed.js` turns a stored frame into
`{tone, actor, join, verb, subject, detail}`, which keeps the names emphasised without any HTML in a
string, and makes the whole thing testable in a runner with no DOM. A death is four sentences, not
one — `player`, `self`, `npc`, `environment` — because the plugin distinguishes them so a reader does
not have to guess, and a fall reported as a kill by nobody is the failure that avoids. **A kind this
build has never heard of renders as itself** rather than vanishing: the server's allowlist has
already decided the row may be seen, so what is left here is presentation, and the honest
presentation of a kind we have no words for is its own name.
**`player.tally` is public and deliberately not in the feed.** It is an aggregate the plugin flushes
once a minute per active player (PROTOCOL.md §8.6), so a feed carrying it would be mostly wood
counts. It is the leaderboard's input, and that is where it shows up.
### 17.2 Four defects the page walk found, and two of them were already shipped
The walk was the whole point of doing one. None of these is visible in a test that stubs a sidecar
which answers.
1. **An unreachable refresh erased what the server last said.** Phase 3's refresh loop called
`putState` — the whole-row write — with two fields when a sidecar did not answer, so `hostname`,
`level`, `seed`, `world_size` and `wipe_id` all went to NULL the first time a game host rebooted.
The list then read `Offline` with nothing beside it, which is not *"here is what we know about a
server that is down"*, it is *"we have never heard of it"* — and it defeats this phase's
criterion exactly. Fixed with `markUnreachable`, which moves three columns and mentions no
others; `server/test/refresh.test.js` asserts against the SQL, because the defect is about which
columns a statement names.
2. **"Last reported" was reading the wrong timestamp.** `updated_at` is when this module last WROTE
the row, which a failed poll does too — so an offline server claimed it had reported just now,
every thirty seconds, for as long as it stayed down. They are two facts and both are wanted:
`updated_at` decides staleness, and a new `last_seen_at` records when a `server.hello` last
arrived. Only a successful refresh moves it.
3. **Every feed row showed a bare time of day.** Correct for today's killfeed and wrong the moment
the feed is filtered to a past wipe: three events from six weeks ago all rendered as `02:03 PM`.
Rows from another calendar day now carry the date. The boundary is the calendar rather than a
duration, because that is what a reader means by "what time was that".
4. **A mistyped address was dressed as a fault.** The detail page rendered core's `ErrorState` under
its own heading, so `/rust/servers/typo` read "No such server / Something went wrong" and sent a
reader looking for an outage. A 404 is now its own answer and `ErrorState` is kept for a request
that failed for a reason nobody can see.
A fifth, smaller: the Online tab listed three players under a header reading `Offline`. An
unreachable sidecar does not clear the presence board — deliberately, the rows are still the best
answer anybody has — but presented bare they read as *who is on right now*, which is the one thing
an offline server cannot be saying. The panel now says which it is.
### 17.3 `useAsync` cannot poll, and that is not a defect in it
Core's fetch hook (§3.4) blanks `data` and sets `loading` on every dependency change. That is right
for a page load and wrong for a poll: bumping a dependency every twenty seconds would clear the
killfeed, render a spinner in its place and re-fill it, four times a minute, for ever.
So the module bundles `hooks/usePolled.js`: a refresh that is **invisible when it succeeds** and
keeps the rows *and* reports the error when it fails — because a site whose premise is "it renders
while the game is off" must not blank itself the first time a request does. `key` (the question)
resets the data; the interval does not. `useAsync` is still the right hook for everything that loads
once, and both are used here.
The live proof: with the tab hidden the log shows no requests at all, and the instant it became
visible there was one refresh followed by one every 20.0 seconds.
### 17.4 The footer slot's real cost, stated
Core renders `SiteFooter` inside `PublicLayout`, and **every public page renders `PublicLayout`
itself** (§3.3) — so a component in that slot mounts once per public page view, not once per
session. D15's live count therefore puts one `/public/rust/servers` request on every public page of
the site, including pages with nothing to do with Rust.
Two things keep that honest rather than merely cheap. It **renders nothing until it has an answer,
and nothing at all if the request fails** — core's `<Slot wrap>` takes its separator with it, so a
failure degrades to exactly the footer an instance with no module installed has. And it **never
polls**: one request per page view is a cost; a timer in the footer of every page is a different
kind of thing. If it ever shows up in an operator's logs, the fix is a short-lived cache in that one
file and nothing else on the site changes.
### 17.5 Smaller things worth keeping
- **The registration fake was *nearly* core, which is worse than obviously not.** `client/test`'s
fake registry prefixed routes as `` `${id}/${path}` ``; core strips the trailing separator too,
which is exactly what lets a module register `path: ''` and own its namespace root. The day a
module did, the fake produced `rust/` where a real core produces `rust`, and the suite failed the
nav check for a link that works perfectly in a browser. The fake now copies core's line character
for character.
- **A SQL comment inside a JS template literal may not contain a backtick.** Obvious written down,
invisible while writing prose about `markUnreachable` inside a query string; the file stops
parsing several lines later and the error names an argument list.
- **The detail route exists so that a page can 404.** Every other route under `/servers/:id` answers
an empty list for an id nobody configured — an unknown server genuinely has no events, no
leaderboard and nobody online, and each of those is a good answer to its own question. Only
`GET …/servers/:id` can say the server is not there. A disabled server answers the same 404 as a
missing one: an operator who switched a server off did not switch it into a 403.
- **`capabilities` grew to what the pages serve** — `servers`, `killfeed`, `leaderboard`,
`presence`, `wipes` — which is the list phase 5's Android leg feature-detects against.
- **The rig had two cores again**, and this time the mechanism was visible rather than inferred: a
leftover core holding an older module release spoke protocol 1, was refused `409`, and rewrote the
state row as unreachable every thirty seconds — the phase-3 trap, and also how defect 1 above was
found. One of the two was stopped with the org lead's say-so before the walk continued.
### 17.6 What was proven, and how
- **The criterion, directly.** A second server was configured pointing at a dead address and seeded
with a fixture shaped exactly as the plugin emits (two wipes, 26 events, four players, a presence
board). With that server unreachable and reporting `Offline`, its page still rendered its map,
size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, last known board and wipe
history. That is the phase criterion in one screenshot.
- **The allowlist, on real rows.** The fixture includes a kind this build has never heard of. The
public route answered `{"events":[]}` for it when asked **by name** — a refusal, not a filter.
- **R12's arithmetic, on the page.** All-time equals the two wipes summed (41 + 18 = 59), and a
player who only appears in the older wipe drops out of the current one rather than reading zero.
- **The footer slot**, live in core's own footer on every public page, linking to `/rust`.
- **The chunk's identity check**, which is the one failure only a browser can show: the console
carried the module's own registration line and nothing else — no second React, no bare import.
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
## 18. Phase 5 as built — Android leg A, 2026-09-17
The first leg of R10, and the first time this module's surface has had a second client. The app's
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
the phase decided and what the walk found.
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
cores.
### 18.0 Four org-lead decisions
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
every started module's capabilities into one list, so a client gating a whole navigation group on
`servers` would have those screens revealed by any future module that declared the same generic
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
changes, the string a client gates on has to change with it. Module-Rust#5.
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
who is not reading.
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
what makes the cutover the release.
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
added link to `/rust`, hands off to a browser rather than opening the native screen.
### 18.1 D15 has no analogue on a phone, so it was translated
The footer slot works on the web because every public page renders the same footer (§17.4). The app
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
for only where the module is installed, so a UO site makes no request at all.
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
is how many people are on — a server count changes when an operator edits configuration, which is not
news, and is on the page the row opens anyway.
### 18.2 What the app had to grow: a refresh that is not a load
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
| Result | What the reader sees |
| --- | --- |
| It answered | New rows. Nothing else. |
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
at. The leaderboard and the wipe list are never polled at all.
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
last wipe's killfeed under this wipe's heading.
### 18.3 What the walk proved, and how
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
A second core on `:3100` serves `module-uo` and no Rust.
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
board and its wipe history. Nothing on the screen is a live call to a game host.
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
/ Leaderboards / Market and **no Rust row**.
- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A
poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
only in the current wipe — **drops out** of the August board rather than reading zero.
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
with reason and session length; and *while sleeping*.
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
board this server sent. It is offline, so this is who was on then — not who is on now."*
- **The badge**, showing a live count on the drawer row.
### 18.4 The walk found three defects, and a green suite found none of them
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
so it was read when the app connected and never again — which is not what *live* means on a row
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
only by backgrounding the app and returning to it.*
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
not, and on a phone the first glyph of each line read as clipped.
3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left
*Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the
**active sort moved to the header**, because the header is the control: tinting a column of numbers
says *these are special* where tinting the header says *this is what the table is ordered by*.
### 18.5 The rig note worth keeping
The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost`
only** — not `10.0.2.2`. An emulator walk against a local core therefore needs
`adb reverse tcp:<port> tcp:<port>` and the loopback address. Typed as `10.0.2.2`, every request fails
with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
running.
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
an `install -r` over an earlier install keeps the stored base URL.
### 18.6 What is not proven here
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
gave the surface a second client rather than re-reading the first, and the pages have still not been
looked at in a narrow browser.
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
by seeding a player count on the unreachable server and reading the badge before the 30-second
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
## 19. Phase 6 as built — identity, 2026-09-21
R1, and R13's first slot. A player proves a Steam account is theirs by typing `/link` in game and
entering the code on the website; an operator sees the result inside core's own user page.
**Criterion met for everything the site owns, and walked in a browser** — the Steam id, the link
date and per-server all-time totals render under core's security panel on `/admin/users/:id`, with a
staff unlink that writes an audit row. **The in-game half is written down as a walk to run** rather
than claimed: a code reaches a player and nobody else, so no console can read one. It is
[`../../rust-link/PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md)'s new *identity walk*, beside the
read-path walk phase 3 left there for the same reason.
### 19.0 The org-lead decisions this phase needed
Four were taken while it was being built (D20-D23) and three during the walk (D24-D26):
- **D20 — `/link` is a CHAT command, not a console one.** The Rust idiom, and what players expect
from every Discord-linking server they have used. Both frameworks consume a `/` command rather
than broadcasting it and `SendReply` addresses one player, so neither the request nor the code
reaches public chat.
- **D21 — pending codes live in plugin memory, matching `module-uo`.** Asked as "where does an
unconfirmed code live"; answered *"match how it works for the uo module"*, and the UO bridge's
shape was then read out of `BridgeAccountLink.cs` rather than guessed: six characters, five-minute
TTL, thirty-second cooldown, one outstanding code per player. A plugin reload drops them, which
matters because phase 7b's config editor will reload plugins routinely — and the cost is a player
typing `/link` again, which is cheaper than an unconfirmed credential in a second process.
- **D22 — the code alphabet has no O, 0, I or 1.** A player reads it off their screen and types it
into a browser, often on a phone, sometimes reading it to somebody else.
- **D23 — a Steam id another website account holds is REFUSED, never moved**, and the refusal names
the holder. Phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a
silent move is an account takeover performed by typing six characters. The way out is `/unlink` in
game, which the player can reach from the machine they are sitting at.
- **D24 — the website asks EVERY configured server, first `link.ok` wins.** A code is minted by one
server and nothing in it says which. Asking the player to pick was rejected: a wrong pick comes
back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous.
- **D25 — staff can sever a link from the `admin.users.detail` panel**, with an activity row. It is
the counterweight to D23: a player who has lost access to that Steam account in game has no other
route back.
- **D26 — the activity-row overflow (§19.4, defect 3) is CORE's to fix, in its own PR.** A module may
legitimately name an action; shortening this module's names only moves the ceiling to the next one.
### 19.1 What the three repos gained
| Repo | What |
|---|---|
| [Rust-Plugins][rp] | `/link` and `/unlink` chat commands, the in-memory code table with its purge timer, and `link.confirm` — the first command the **website** originates. `checkPlugin.js` grew a check for chat commands, which bind by reflection with the same silent-failure mode as hooks |
| [Rust-Link][rl] | `POST /link/confirm` — the first route on this sidecar that is not a GET. It forwards and nothing else: it does not mint codes, does not store them, and cannot tell a good one from a bad one |
| [Module-Rust][mr] | `rust_account_links`, the fleet loop, the player page at `/player/rust`, and both halves of `admin.users.detail` |
**A refused code is a `200` all the way up.** `link.ok` and `link.error` are both answers; the
sidecar keeps its own status codes for the transport (503 game down, 504 game silent), because the
website has to tell *"that code is wrong"* from *"the game never replied"*.
### 19.2 `admin.users.detail` is declared in three places, and they are three different registries
The slot cost more wiring than its size suggests, and each of the three is held by a different thing:
1. **`module.json`'s `extensions` array** — validated by the loader against the **server** registry.
Naming a client slot there fails the load outright, which phase 1 found the hard way with
`site.footer.status`.
2. **`api.registerExtension('admin.users.detail', router)`** in `server/index.js` — the routes,
mounted inside core's `/api/v1/admin/users/:id` with `mergeParams`. Without that flag
`req.params.id` is `undefined` and every statement in the panel silently scopes to nobody.
3. **`registry.registerExtension(ID, 'admin.users.detail', Component)`** in the chunk — the panel.
Core passes it `userId` and nothing else, so it builds its own client for the routes the server
half registered.
**The gate is core's and it is stricter than the admin tier's.** Core's users router is
`requireRole('admin')` and the slot is mounted inside it, so editors and moderators never reach these
routes — which is right for a surface that can sever what phases 7 and 13 grant against.
### 19.3 The hole the slot found in this repo's own OpenAPI generator
`swaggerFragment.js` ran `register()` against a recording api and walked `record.routes` — the three
tiers. A slot router is not registered under a tier, so **the two routes under `/admin/users/:id`
were generated by nothing**: a fragment that was internally consistent, passed every check in the
repo, and described two routes fewer than the module serves.
A slot's mount is **core's**, so it cannot be derived from anything here — it is a fourth constant
beside `TIER_BASE`, and like `TIER_BASE` it is held to account by a real core in the frozen-manifest
job. That check was verified to catch exactly this, by deleting the two paths from the fragment and
watching it fail.
`test/frozenManifest.test.js` grew the other half. Its *mounts agree* case was written in phase 1
with this phase named in a comment — *"when the slot arrives this test must grow the exception
deliberately, rather than a route outside every declared mount arriving unnoticed"* — and it failed
on the first run after the slot was filled. It now also fails when a **declared slot contributes no
route**, because core never checks that a declared slot was filled.
### 19.4 Three defects the browser walk found, and 122 green tests did not
1. **Every refusal sentence was invisible.** Core's request primitive is the only thing that reads a
module's failures, and it reads one field:
```js
const message = (data && data.message) || res.statusText || 'Request failed'
```
This module has answered `{ error: … }` since phase 1 and got away with it, because until now
every failure landed in `ErrorState` on a page whose whole content was missing — where a generic
sentence is honest. **A form is different: the sentence IS the outcome.** The link page showed
*Service Unavailable* for all four of the refusals this phase exists to write. All 23 error bodies
in the module now answer in `message`, and `test/errorShape.test.js` drives each outcome rather
than grepping for the field.
It is also a correction to phases 1-4, which shipped the wrong shape while referencing core's
`Error` schema — `{ message }` — in their own `#swagger.responses` annotations.
2. **The player saw a stale name.** `/player/rust` showed `Wanderer-old` — the name recorded at link
time — while the admin panel showed `Wanderer`, the name the game last saw. Only the admin read
joined `rust_players`. The same person, labelled two ways on one site, because a Rust name changes
on a whim.
3. **Core's activity row collides with a long action name.** `Dashboard.jsx` renders the action in a
`width: 110`, `flex: 'none'` span with no overflow handling, so `rust.account.unlink.staff`
overlaps the detail text beside it. D26 sends it to core as its own change.
### 19.5 What the walk proved, and how
Against a core at the pinned ref with the module installed, two configured servers (one sidecar up
with no game behind it, one address with nothing listening) and three logins:
- **The criterion**: `/admin/users/2` rendered the Steam id, *linked last month on rust-oxide*, *last
played 12 hours ago*, and per-server all-time totals — 59 kills across two wipes on one server, 3
on another — under core's own security panel.
- **D25**: Unlink removed the row, the panel then rendered *nothing at all* (most users have no Rust
account, and a "no linked accounts" notice on every user page is noise), and
`rust.account.unlink.staff` landed in the activity log naming the operator.
- **The player page**: the link row with its own Unlink, the empty state, and the three-step
instruction that is the only place on the site a player learns the code comes from the game.
- **A refusal that is a sentence**: with both sidecars unreachable, *"The game servers are unreachable
right now — try again in a minute."* — which is what defect 1 above was hiding.
- **Ownership**: a second player deleting the first player's link gets the same `404` as one that does
not exist, so a signed-in stranger cannot discover linked Steam ids by deleting them one at a time.
A player reaching the admin slot route gets `403` from core's own gate.
- **R1's rate limit, live**: ten attempts pass, the eleventh answers `429` with *"Too many link
attempts."* The limiter is per-IP, like core's own login limiter — which means two players behind
one address share the allowance, and that is core's policy rather than a choice made here.
### 19.6 What is not proven here
- **The code from the game.** The rig booted with the phase-6 plugin loaded and announcing protocol 3,
and could not reach the sidecar on the development machine: no inbound firewall rule for TCP 7800
on this Windows host, which is not a change to make from a session. The plugin half's own checks
are green and its shape is the UO bridge's, proven; what is untested is the whole path with a
person in it. **D27 (org lead): it goes in the manual walk document**, as its own *identity walk*
beside phase 3's player walk.
- **`unsure` against a real refusal.** Proving it needs one server that genuinely refuses a code —
which needs a plugin connected — alongside one that is down. The branch is unit-tested and its
sentence was read in a browser; the live combination is step 6 of the identity walk.
---
## 20. Phase 7 as built — site-owned permissions, 2026-09-21
**The site's half is built and walked; the in-game half is written down as a walk to run.** R2 is
the direction the Integration Kit has no chapter for (§2, R2), and building it once is what phase 19
was told to wait for. It is also the first phase where this module writes to a game.
Five repositories touched: `Rust-Plugins#4`, `Rust-Link#4`, `Module-Rust#8` (all into `edge`), this
document, and nothing in `website` — core needed no change, which is itself worth recording after
phases 5 and 6 both found something missing in the contract.
### 20.0 The seven decisions this phase needed
| | Decision |
|---|---|
| **D28** | **A grant is keyed to the website USER**, resolved to every Steam id they have linked at push time. Not to a Steam id: the site authors privilege for a *person*, phase 13's earned entitlements follow whoever earned them, and unlinking an account takes its privileges with it. The cost, which is real and appears again in §20.5, is that a person with two linked accounts holds it on both |
| **D29** | **Every authored row carries a scope** — one server id, or `*` for the fleet. A modded server and a vanilla one will not want one set on both, and a single-server community never sees the choice |
| **D30** | **Groups are mirrored as real groups**, not flattened into per-player grants. Third-party plugins read group membership, R15's BetterChat group API (phase 17) has something to hang on, and an operator reading `oxide.show groups` sees what the website shows. The price is §12.2 rule 4: a player the store has never seen cannot be put in a group, while a direct grant to the same account works immediately |
| **D31** | **A holder the site did not author is reported, never undone**, and an operator is offered two answers: adopt it (the site maintains it from then on) or revoke it (removed on the next sync). A console grant during an incident must survive the next reconcile |
| **D32** | **One verb, and the plugin does the diffing.** The site sends the whole desired set; the plugin compares it against the live store and writes only the difference. Shipping the store to the website was rejected: it is the bigger of the two sets, and a copy of it is a second source of truth that is stale the moment it lands |
| **D33** | **Never self-register a permission.** The form offers what the servers report; a name that stops resolving is reported unresolved and the grant is kept. §12.2 rule 3 says `RegisterPermission` would make it stick with a console warning — doing so fabricates a permission the operator never installed, inert until some plugin happens to check it |
| **D34** | **People and groups, by hand.** Rules that keep themselves true — "everyone in the Donators team is in `vip`" — are genuinely wanted and are a second authority over the same rows, with its own reconcile. Deferred |
### 20.1 Three sets, and every interesting question is a difference between two
```
desired − pushed apply
pushed − desired RETIRE, because the site put it there and has since withdrawn it
present − desired drift
```
The middle row is the whole reason `rust_perm_pushed` exists, and it is the part that cannot be
inferred from anything the game knows. **A name in the store that is not in the desired set is
either something the site retired or something a human granted**, and those two have opposite
correct answers. The store records who granted a permission nowhere, so the site's own memory of
what it pushed is the only thing that tells them apart.
That table is keyed by **Steam id**, not by user, because it records what is in the GAME and the
game has never heard of a website account. Unlinking therefore leaves its rows until the next sync
retires them, which is correct and would be inexpressible keyed the other way.
**Revoking drift needed its own table**, and the reason is a good one: a foreign grant often names a
Steam id no website account holds, so there is no user to author it against and nothing in
`rust_perm_pushed` to remove. `rust_perm_revocations` is an instruction with its own lifetime —
queued by a person, carried in the next sync's `retire` list, deleted when a report comes back. A
server that is offline keeps the instruction until it returns, which is what an operator expects
from a site that claims to be the author of record.
### 20.2 What lands is not what was sent
Two outcomes look exactly like success from the website and are not, and both are reported by the
plugin rather than assumed:
- **`unresolved`** — no loaded plugin on that server registered the name, and `GrantUserPermission`
no-ops silently for one (§12.2 rule 1). This is the finding with teeth that phase 0 produced, and
phase 7 is where it is actually paid for.
- **`pending`** — the store has never seen that player, so a group membership cannot be placed
(§12.2 rule 4). The plugin reads the membership back after adding it, because a void return and no
change is the only signal available.
**Neither is recorded as pushed.** A site that recorded them would believe it had given a privilege
it had not — and would later "retire" it from a server that never had it, a no-op that reads as a
success in every log.
### 20.3 The loop, and what provokes it
Every 30 seconds it compares a digest of the desired set against what each server last confirmed,
and does nothing when they match. A sync happens when the set changed, when the game restarted or
wiped (the boot-id and wipe-id watch), when a `perm.drift` hook arrived, when the last attempt
failed and its backoff has elapsed, or every 15 minutes regardless — the audit is what finds drift
on a server nobody has touched.
**The digest is sorted before hashing.** The rows come out of five queries in an order nothing
guarantees, and an unsorted digest differs between two reads of an unchanged set — which would push
to every game server on every tick, for ever.
`dirty` is an optimisation rather than the truth, and it is written down as such: the loop's real
condition is `desired_hash != synced_hash`, recomputed from the tables every tick, so a flag cleared
while a sync was in flight costs nothing.
### 20.4 Nothing the far side sends may cost the main thread unbounded work
`perm.sync` is the first command whose work is not bounded by its own shape. A community with two
thousand linked players sends thousands of store operations in one frame; applying them in the tick
they arrive is a freeze an operator will blame on the game.
The plugin compiles a sync into single-store operations and drains 200 at a time on a timer,
reporting when the last one lands. Compiling touches nothing, so an oversized or malformed sync is
refused before there is any state to unwind. **This is §5's no-stall rule pointed at the inbound
half**, and it is new: every earlier command was a request to repeat something the game already knew.
The three bounds are each on the side that can say something useful when one is hit — ~15,000 rows
at the website (which can name the server to an operator), 1 MiB at the sidecar (the game link's own
line cap; forwarded, the line is discarded silently and presents as a `504`), 20,000 operations at
the plugin.
### 20.5 Four defects a browser walk found that 133 green tests did not
1. **A person with permissions and no linked Steam account had the whole Rust section hidden** on
core's user page. The section was gated on `links.length`, phase 6's rule, and phase 7 gave it a
second reason to exist — *for exactly the person whose grants reach nobody*, which is the state an
operator most needs to see. It is the same class of defect as phase 6's invisible refusal: a
correct decision that a later phase quietly invalidated.
2. **A member waiting on a first connection looked like an ordinary member.** The count was in the
server strip; nothing was beside the person. Fixed by matching each member's accounts against the
`pending` entries in every server's report — which also required the overview to carry *all* of a
member's Steam accounts rather than whichever one the join returned first.
3. **A grant naming an unregistered permission carried no warning**, though a group's permission
list had carried one from the start. Same fact, two places, one of them missed.
4. The page drew its own `<h1>` under core's chrome title — the same words twice.
**A fifth was found by a test, and it was the better catch.** `buildDesired` resolved a grant's
Steam accounts from the **join** in `listGrants` rather than from the link map, which gives the
right answer by accident: the join repeats a grant per linked account. It would have kept giving the
right answer until somebody changed that query, at which point one of a person's two accounts
quietly stops being granted. The test that caught it was written against a fixture with one row per
grant — the fixture was wrong about the query and right about the model.
### 20.6 What was proven, and how
A real core at the pinned ref with the module installed, the real `rust-link` sidecar, and a
**stand-in plugin** speaking protocol 4 on the loopback — a Node script implementing the store
semantics that matter (unregistered names no-op; an unseen player can hold a grant but not a
membership; `GetPermissionUsers` answers direct holders only).
- a group created, its permission added and two memberships applied on the first sync — two, because
one member holds two linked Steam accounts (D28 on the wire);
- `unresolved: ["kits.gold"]` and `pending: ["7656003:vip"]` reported, and **absent from
`rust_perm_pushed`**;
- a hand edit found as drift, adopted, and the refusal for one whose Steam id belongs to nobody:
*"That Steam account is not linked to any account on this site, so there is nobody to author this
against."*;
- a drift row revoked — queued, carried on the next sync (`revokes: 1`), the queue row cleared;
- a withdrawn grant retired (`pushed − desired`), and its pushed row removed;
- **a restart that emptied the store**: `reason: "restart"`, the whole set re-pushed, group,
permission, memberships and grant all back. That is R2's central promise, walked;
- a `perm.drift` frame marking the server dirty through the ingest, and the next sync reporting the
hand edit authoritatively — the live signal and the authoritative answer, in the division §10.4
describes;
- an unreachable second server reporting `transport-error` and retrying on its backoff while the
first stayed in sync;
- the module's chunk alone in the browser console: no second React, no bare import.
### 20.7 What is NOT proven, and who it is waiting on
**The acceptance line — "a grant made on the website gates a third-party plugin in-game" — is not
met**, and the reason was known before the phase started (§12.5): no console session can observe a
permission gate, and an admin account bypasses PopupNotifications and ZoneManager unconditionally.
It needs a **second, non-admin Steam account** on the rig, which is the org lead's to arrange. The
phase was built on the understanding that the in-game leg becomes a walk to run (as D27 did for
identity), not a claim.
**Nothing in the plugin has been compiled.** Two specifics for whoever runs that walk:
- `GetPermissionUsers` and `GetUsersInGroup` answer with `id(name)` and the spacing differs between
the two calls and between the frameworks. The plugin takes everything before the first bracket;
confirm it on both rigs, because a parse that is wrong here reports every holder as foreign.
- `GetGroupPermissions(name, false)` is called with both arguments. If Carbon's signature has no
second parameter this does not compile there — the one place in this change where R19's
"byte-identical plugin" claim is at risk.
### 20.8 Smaller things worth keeping
- **A mount prefix is ONE path segment.** Core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`, so
`/rust/permissions` cannot be declared in `module.json` and has to be a nested `use()` under
`/rust`. The useful half: **swagger-autogen follows the `require` and generates it with the right
prefix anyway** — the exact opposite of phase 6's hole, where the registration walk could not see
a slot router. The generator sees a nested router the walk cannot.
- **This module's first admin page**, and the first thing in it that had to be *authored* rather
than configured. The server rows are still configured through the API and have no screen —
a gap this phase deliberately did not widen into.
- **There is no module-declared site permission at MODULE_API 1.10.0**, so `requireRole('admin')` on
every route is the whole of the available vocabulary. R18's "its own site permission" for the
config editor needs a contract member that does not exist; phase 7b will meet the same wall.
- **Adopting a hand grant widens it.** It becomes a grant against the *person*, so it reaches every
Steam account they hold — one consequence of D28 that only shows up when adopting, and the next
sync applies it to their other accounts. Correct, and worth saying out loud on the screen one day.
- The rig had a leftover core **and** a leftover sidecar from phase 6 still running, and the sidecar
held the release binary open so `cargo build` failed with `Access is denied (os error 5)` and
**exit code 0**. Two sessions' rigs on one machine is now a standing hazard; check ports 3200,
8090 and 7799 before starting anything.
---
## 21. Phase 7b as built — configuration from the site, 2026-09-22
**The site's half is walked end to end against a real sidecar; the plugin's half is proven to
compile and load on a live Oxide rig, and the in-game leg is still blocked on the same firewall rule
phases 6 and 7 hit.** R18 is the second thing this module does *to* a game, and the first that
writes to the game host's filesystem.
Four repositories touched: [`Rust-Plugins#5`][rp], [`Rust-Link#5`][rl], [`Module-Rust#9`][mr] (all
into `edge`) and this document. Core needed no change again — the second Rust phase running.
### 21.0 The four decisions this phase needed
| | Decision |
|---|---|
| **D35** | **The website composes the file's bytes; the plugin writes them.** The generated form sends pointers and literals, a span-splicing editor on the Node side produces the new text, and `config.write` carries whole file text. The alternative — sending key/value edits for Newtonsoft to apply, where C# genuinely can tell `1` from `1.0` — was considered and rejected: it gives the plugin authority over content, puts the decision in a place with no test for it, and still needs the text path for the raw tier. |
| **D36** | **A number travels as the literal an admin typed**, all the way from the browser to disk, and never becomes a JavaScript number. `2.50` stays `2.50`. |
| **D37** | **The raw-JSON tier shows real values, including credentials.** An admin can already read the file over SSH, and a masked raw tier would need the server to splice the originals back into whatever was submitted. The generated form still masks a credential-shaped field behind a *Show* control, and **the audit trail never records the values either way** — a log is read by more people, for longer, and usually by somebody who was not there. |
| **D38** | **The bridge's own config is editable, with three keys locked.** `Host` and `Port` carry the link the edit is travelling over; `ServerId` keys every row this site holds. All three render read-only with the reason; `QueueCap` and anything added later stay editable. Hiding our config entirely was rejected — it reverses D3 — and so was warn-only, because a typo in `Port` orphans the server with no path back. |
### 21.1 The trap, and what it cost to avoid
**JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` is `1` and `JSON.stringify`
writes `1`, while both frameworks deserialize into typed C# classes — so a read-modify-write
silently rewrites every whole-numbered float as an integer *on fields nobody touched*, and Newtonsoft
may coerce that or may throw. A throw at load is a plugin that does not come back, and R6/R17 make
four of them required.
`server/configEdit.js` is the answer: a JSON reader that records the **source span** of every value,
and an `applyEdits` that splices literals into those spans from the end of the document backwards.
Nothing parses, mutates and re-serialises. Everything a save did not touch is byte-identical —
indentation, key order, and the `.0` on a float.
Its suite asserts the failure as well as the fix: the same document through `JSON.parse` /
`JSON.stringify` loses `1.0`, `2.50` and `1e3`, and the test says so in the same breath as proving
the splice keeps them. **A walk on the live rig wrote `2.50` into `ZoneManager.json` and left
`500.0` and a `null` exactly as they were.**
Two rules fall out and both are deliberate: a number's new value arrives as **text** an admin typed,
and the generated form is **type-preserving** — changing what a value *is* belongs in the form,
changing what *kind* of thing it is belongs in the raw tier.
### 21.2 The rollback is the feature, and the window is arithmetic
The plugin backs the set up, writes it, reloads the target through the framework, waits four seconds
for `OnPluginLoaded`, and — if it never arrives — **restores every file, reloads again, and reports
the failure with the tail of the newest log file.** Without that, R18 is a web form that takes a
required plugin off a production server one typo at a time.
Four seconds is not taste. The worst path is two windows — wait, give up, restore, wait again —
while the caller holds a socket, so it has to fit inside the sidecar's 10s `REPLY_TIMEOUT`. The
sidecar mirrors the number as `web::CONFIG_RELOAD_WINDOW` and **a test asserts the inequality**
rather than trusting the comment, in the shape phase 3 established for `budgetMs`.
Walked on the rig: a save whose reload never announced itself came back `200` with
`rolledBack: true`, the file on disk was the original, and the compiler line was on the screen.
**A rollback is a round trip that worked carrying bad news** — reporting it as a 5xx would throw
away the only diagnosis there is.
### 21.3 The bridge will not reload itself
Reloading this plugin would unload it, close the link the answer travels on, and leave a rollback
with nothing watching it: the one failure the whole mechanism exists to report would be the one it
could not. `reload-self` is refused at the plugin, the website leaves it out of the dropdown, and
our own settings apply on the next deliberate reload.
That produced the phase's best defect (§21.5, #1).
### 21.4 What the walk proved, and on what
Two rigs, because the site half and the plugin half fail in different places.
- **The site half** ran against the real sidecar and a stand-in plugin over a real directory of real
config files: the recursive walk (including a nested `Kits/kits.json`), a form save that reloaded,
a save that rolled back, a refusal, a write with no reload, a version conflict with the current
file handed back, and the locked keys. Five outcomes, all five in the audit trail.
- **The plugin half** ran on **both live rigs**, from one byte-identical file. On Oxide 2.0.4143,
1,050 new lines of C# `compiled successfully in 0ms` and the plugin loaded; on Carbon 2.0.259.0 it
loaded in 3,267 ms. `rg.config` answers
`protocol=5 framework=oxide root=/home/container/oxide/config` on the first and
**`protocol=5 framework=carbon root=/home/container/carbon/configs`** on the second.
That is two things at once and the second is the more valuable. `Interface.Oxide.ConfigDirectory`
resolves to a *different directory* on each framework, exactly as R18's amendment predicted — a
literal `oxide/config/` in this editor would have missed every config on half of all installs — and
the runtime framework detection, which the `c.reload` fallback depends on, agrees with the host it
is running on. Neither needed a line of conditional compilation, so R19's claim survives protocol
5.
**The acceptance line is not met**: "an admin flips a ZoneManager setting from the website and it
takes effect" needs the plugin and the sidecar on one host, and the rig's plugin still cannot reach
a sidecar on the development machine — two `Block` rules for `rust-link-sidecar.exe` in Windows
Firewall, the same wall phases 6 and 7 stopped at. It is the same walk each time and it is written
down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
### 21.5 Four defects a browser found that 179 green tests did not
1. **Every save of the bridge's own config was refused**, with the page saying the opposite. The
reload target was seeded from the file's guessed plugin — `RunicGateway` — which is deliberately
*not* offered in the dropdown, and **a `<select>` whose value matches no `<option>` displays the
first one**. So the screen read "nothing — just write the file" while the request carried
`reload: RunicGateway`, and the plugin refused it for a reason the page had just said did not
apply. D38's "editable except three keys" was worth nothing. A guess is now only taken when the
dropdown actually offers it.
2. **`btn ghost` is not a class this platform defines** — core's CSS has `.btn-ghost` — so every
"secondary" button in this module has rendered as a primary one **since phase 7**, and on this
page it meant the open file and the active tier were indistinguishable from the closed and
inactive ones. An unknown class fails silently: the button still renders, just as the wrong one.
Fixed here and on the three pages phase 6 and 7 shipped.
3. **The save's refusal rendered at the top of the page**, while the button that caused it is at the
bottom of a long form — a click that visibly did nothing. It is beside the button now.
4. **Core's module loader cannot see a symlinked module directory.** `loader.js` filters
`readdirSync(..., { withFileTypes: true })` on `isDirectory()`, and a Windows junction is a
symlink, so junctioning the working tree into a rig's `modules/` makes the module silently
invisible. Not a product defect — nothing ships that way — but it costs a confused restart, and
the rig recipe now copies rather than links.
### 21.6 Smaller things worth keeping
- **`config.list` hashes nothing.** A version comes from `config.read`, on the one file somebody
opened. Hashing 500 files would be up to 128 MB of reads in one frame, which is the unbounded
main-thread work §10.5 forbids.
- **The reload target is a field with a guess, never an inference.** A folder name is convention,
not contract: infer it silently and the wrong plugin is reloaded, `OnPluginLoaded` fires for *it*,
and the write reports success while the edited plugin never re-read anything.
- **A file past a limit is listed and marked, never hidden.** An operator who cannot find a file they
know exists goes looking for a bug in the bridge.
- **`rewritten` is normal**: both frameworks merge missing defaults on load and save the file back,
so the file after a good reload is regularly not the file that was sent. The report says so.
- **The version is an FNV-1a hash, not a digest.** Nothing here is a security claim — the website
never computes one, it only echoes back what it was given — and one fewer namespace has to be
available under two plugin compilers.
- **A path from a web form is a traversal surface**, and the guard lives on the host: canonicalise,
assert under the root, refuse absolute paths, drive letters, `..` and reparse points. The sidecar
forwards the path and judges nothing, because only the process holding the directory can decide;
the website checks the *shape* to save a round trip and never pretends that is the boundary.
- **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so
`requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member.
## 22. Phase 8 as built — the player's own half, on two screens, 2026-09-22
**Walked end to end against a live rig, and the rig is why this phase reads differently from the
three before it.** The sidecar now runs inside the game container, so for the first time since phase
5 the website talked to a real plugin on a real Oxide server rather than to a stand-in — including
the permission push, which landed a group membership in Oxide's own store and reported
`unresolved` for a name nothing had registered.
Two repositories and this document: [`Module-Rust#10`][mr] (the website half, into `edge`) and
[`Android-app#48`][aa] (M15, into `edge`). Core needed no change — the third Rust phase running.
### 22.0 The three decisions this phase needed
| | Decision |
|---|---|
| **D39** | **The app's permission half is player-facing and read-only, and the website grows the route for it.** Phase 7's whole surface is `requireRole('admin')`, and the app has no admin user-detail screen to port it into; porting the authoring page to a phone was considered and rejected (it writes into a running game from a device that is easy to mis-tap, and M14 already excluded the Rust admin surface as configuration the app consumes rather than edits). So the phase adds one self-scoped read instead — and renders it on the **website** as well, so the app never has a surface the website lacks. |
| **D40** | **It mirrors `module-uo`'s player surface, not a shape of its own.** One drawer row under the player group, with the code card at the top of the screen it leads to — exactly `CharactersScreen`. A tab under one server was rejected because a link is fleet-wide, and a section inside core's own Account screen was rejected because the app has no slot mechanism and the module's data would be hard-wired into a core screen. |
| **D41** | **The row hangs on `rust`, matching `module-uo`'s single `shard`.** The module declares a surface word per feature and `identity` is one of them, but D16's rule stands: a capability answers *is the module there*, and core flattens every module's capabilities into one list. Requiring `rust` **and** `identity` was considered — it would refuse the screen on a build predating phase 6 — and rejected as a second meaning for a word that has one. |
### 22.1 What the player is told, and what they are not
`GET /player/rust/permissions` is a different shape from the admin read rather than a filtered one,
and the three differences are the phase:
1. **The scope is resolved on the server.** A client handed `scope: "*"` would have to know what the
fleet is to say anything, and then `inScope` exists twice. Each entry arrives carrying the
servers it reaches, each already marked.
2. **`live` is the pushed ledger, never the authored row.** Phase 7 is careful never to record a
push that silently did nothing, so *waiting* here means waiting — and the alternative is the site
claiming to have given something it has not.
3. **Nothing says why it is waiting.** An offline server, a permission no loaded plugin registered
and a store that has never seen the account are one state on this screen. Telling them apart is
an operator's diagnosis and an inventory of what is installed on a host.
**An entitlement that reaches nobody still lists**, and both surfaces say so: authored against the
website account, it exists before a Steam id does. Hiding it until one turns up is the defect the
admin user page shipped in phase 7 (§20.5) — the same mistake, one tier along, caught before it
shipped this time because the rule was already written down.
**The honest limit, named rather than designed around:** a *rank* can be live while every permission
it carries resolves nowhere. The rig proved it — `vip` was created in Oxide's store and the
membership landed, while `kits.vip` came back `unresolved` because Kits is not installed there. The
player is told the rank reached the game, which is true and is what the site gave them; whether a
server's plugins understand the names inside it is on the admin screen, where the warning already
is.
### 22.2 The refusals, on a phone
The app's convention since M1 is that a refusal is chosen by **status** and rendered from a string
resource — the website's sentence is never displayed, because the app is localized and the website
is not. That convention holds here and it costs something worth writing down: the module
distinguishes *three* 503s (a server it could not reach, a fleet that is all down, a site with no
servers configured at all) by sentence, and the app has one string for the status. It is written to
be true of all three, and it does **not** say "get a new code" — a player told that would go back to
the same unreachable server for another one.
The four that do differ by status keep four different pieces of advice: 400 a spent code, 409 a
Steam account another website account holds, 429 the limiter, 503 a server that could not be
reached.
### 22.3 What the walk proved, and on what
- **The website half** in a browser, against the live rig: the section rendering with zero linked
accounts, a rank marked *has it* and a grant marked *waiting* on the same screen, the note that
explains why, and the refusal path.
- **The app half** on an emulator against the same core: the row absent when signed out and absent
on a UO site, present for a signed-in player; both reads; a refused code rendering **beside the
button**; the marks; and the release.
- **The push itself against a real Oxide store**, which is new. `perm.sync` created the group,
applied the membership, and reported `unresolved: ["kits.vip", "zonemanager.admin"]` — the second
of those a genuine finding about the rig rather than the code: ZoneManager registers
`zonemanager.ignoreflag.*` and friends, and **not** `zonemanager.admin`. A permission name that
looks obvious is still a name some plugin has to have registered.
**The acceptance line needs a person in game.** Everything above used a link row written directly
into the rig's database, because a `/link` code reaches a player and nobody else. The three minutes
that close it are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
### 22.4 Smaller things worth keeping
- **`MenuAccess.PLAYER` is `isPlayer || isStaff`**, and that is right here: `/player/rust/*` is
`requireAuth` with no role above it, and staff play the game too. The app's own gating test now
asks *which* module a row on a module path declares, not merely whether it declares one — a
second game under `player/` is a new way for that test to have been passing for the wrong reason.
- **The website's pill carries the word, not only the dot.** A filled circle beside a hollow one is
the whole difference between "you have this in game" and "you do not yet". Found by looking at it.
- **The app's row now says when and where an account was linked**, which the website's always did.
Which server minted the code is not part of the identity — a link is fleet-wide — but it is where
a support conversation starts.
- **`/player/rust` has no app deep link**, deliberately: `module-uo`'s player screens have none
either, and the app's web-path table is the *public* nav's. Phase 10 is where it will matter, when
a notification about an entitlement wants somewhere to land.
- **A rig timestamp written by a different connection is five hours out.** The `DATETIME` columns
here are naive, and they round-trip correctly only through the connection that wrote them —
inserting a stand-in row with `docker exec mariadb` made the site read it as the future. Not a
product defect; a rig recipe.
---
## 23. The presence fix — nothing names who is online by default, 2026-09-22
Not a numbered phase: a correction to what phases 4 and 5 shipped, landed **before** phase 9 on the
org lead's instruction. It came out of phase 9's roster question. Asked who may see a clan's roster,
the org lead answered with a rule wider than the phase:
> *Nothing should tell who is online by default — that should always default to the lowest blast
> area, like members and admin. We can have a default online count but not naming players.*
Measured against that rule, the public site had been breaking it since phase 4, in three places: the
server page's **Online** tab named every connected player, the public **feed** carried
`player.connected`/`player.disconnected`, and the Android app (phase 5) rendered both.
As built: [Module-Rust][mr] `fix/presence-visibility` and [Android-app][aa]
`fix/rust-presence-visibility` (both into `edge`). Core needed no change.
### 23.0 The decisions this needed
| # | Decision |
|---|---|
| **D42** | **Nothing names who is online by default.** Staff (admin or moderator) unless an operator widens it. A *count* is public at every setting — it is already on the server list and in the footer, and it names nobody. |
| **D43** | **Every kind that says a named player was on is gated**, not only connects: `player.connected`, `player.disconnected`, `player.respawned`, **`player.death`**, **`player.chat`** and `player.tally`. The org lead chose "gate all of them" over keeping the killfeed and chat public as content, so a default install's public feed carries only the server's own story (a wipe, a start, a shutdown). |
| **D44** | **Three rungs — `staff` · `signed_in` · `public`** — named to line up with phase 14's map-layer switches (public / players / admin) so that one layer can adopt them rather than sit beside them. A "linked players" rung was offered and not taken. |
| **D45** | **A fleet-wide default with an optional per-server override.** A PvE or creative server may reasonably publish a roll call a PvP server must not. A server with no override follows the fleet, so narrowing the fleet narrows every server that never chose otherwise. |
| **D46** | **The fix is its own PR, before phase 9**, not a commit inside it and not deferred to phase 14. |
### 23.1 "Who is online" was wider than the tab of that name
D43 is the org lead's; the fourth surface was found by reading the leaderboard. A leaderboard row
carries `lastSeen`, and a gather tally is flushed every minute a player is on — so a `lastSeen` of
forty seconds ago is the Online tab by another name. It is withheld below the audience too, and the
website drops the column rather than render a row of dashes that read as "never". The rows' **order**
still breaks ties on it, which says who was on more recently and never whether anybody is on now.
### 23.2 The viewer is re-read, not decoded
`ctx.auth.getUserFromRequest` decodes a token and nothing more: the role in it is the role the
account had when it signed in. For a gate on who may see who is online that is not good enough, so
the module re-reads the `users` row through `ctx.users.getById` (MODULE_API 1.1.0, previously unused
here) and takes role and status from it. **Walked:** a moderator demoted in the database mid-session
lost the roll call on the same cookie, on the next request. A banned account reads as anonymous.
Any failure narrows. An unknown stored audience reads as `staff`; a viewer who cannot be resolved
reads as anonymous; a setting that cannot be read withholds rather than answering 500 or publishing.
The asymmetric pair of fallbacks is `module-uo`'s shard-visibility rule, copied rather than
rediscovered.
### 23.3 The wire shape, and why it did not change
`GET /public/rust/servers/:id/online` keeps its shape and adds three fields: below the audience it
answers `{ players: [], hidden: true, count, audience }`. A client that predates the flag renders an
empty list rather than breaking — wrong, but not a crash — and a current one says "2 players online"
and who can see the names. The feed adds `presenceHidden` and `presenceAudience`. All three routes
answer `Cache-Control: private, no-store` with `Vary: Cookie, Authorization`, because a shared cache
that stored a moderator's answer would hand the roll call to the next anonymous visitor.
Settings live in a new `rust_settings` key/value table (phase 9's roster audience is the next key)
and an optional `presence_audience` column on `rust_servers`. `GET/PUT /admin/rust/visibility` is
`requireRole('admin')` over the tier gate, validated whole before anything is written, and every save
is one activity-log row. The admin page is **Rust visibility**.
### 23.4 A defect four phases old: every empty state was a blank box
Core's `EmptyState` renders its **children** and nothing else. This module had passed it `title` and
`message` since phase 4 — the shape the Integration Kit's template teaches (`Clans.jsx`) — and React
drops an unknown prop without a word, so **every empty panel in the module rendered as an empty
box**: "Nobody is on", "No scores yet", "No servers yet", all six of them. It surfaced only because
the presence fix's own message ("2 players online") depended on one; 169 server tests and 39 client
tests could not see it.
Fixed **module-side** on the org lead's steer ("we can't change too much, we do not want to break
other integrations like the UO module"): a small `Empty` wrapper renders a heading and a sentence as
children of core's component, so nothing core or `module-uo` renders changes. A client test now
refuses a titled `EmptyState` — and a `PageHeader subtitle`, the same class of bug the kit shipped in
the Teams work — and was proven by planting an offence and watching it fail.
**Owed to the kit (phase 19):** the template's `Clans.jsx` teaches the wrong prop. Corrected in its
own Integration-kit PR alongside this one.
### 23.5 What was proven, and how
- **API, four viewers × three routes**, against a core at the pinned sha with the module installed
and a stand-in sidecar serving a board with two players and a feed with a join, a death, a tally
and a chat line: anonymous and a signed-in player got `count: 2` and no names, a feed of wipes only
and no `lastSeen`; a moderator and an admin got all of it.
- **The writes:** a moderator's `PUT` 403s; an unknown audience 400s; a request naming one unknown
server 404s and writes nothing; fleet → `signed_in` opened the names to a player and not to
anonymous; a per-server `public` override opened them to anonymous on that server alone; `null`
put it back on the fleet default.
- **The browser**, signed out: the Online tab reads *2 players online · Only this site's staff can
see who they are*; the feed says joins, deaths and chat are not shown; the leaderboard has no
"Last seen" column. The admin page was proven by API only — the walk does not type a password into
a browser — and is left for the org lead's review.
- **The emulator**, signed in as a player: the same two messages at the staff default, and the names
arriving on the next poll after the fleet was widened to `signed_in` — which also proves the app's
bearer session reaches the module's viewer check.
180 server tests, 40 client tests, 652 app tests, `check:imports`, `check:bundle`,
`check:externals`, the regenerated OpenAPI fragment and the frozen route manifest.
---
## 24. Phase 9 — Teams from first-party clans (plan of record, 2026-09-23)
R5's phase. Rust's **own** clan system becomes core's Team provider: a clan is a Team, its page is
this module's, and core's three contributions (the notification control, the activity feed and the
forum) land in places the module names. It was preceded by the presence fix (§23), which this
phase's roster question produced, and it builds on that fix's settings table.
Three repositories and this document: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr],
all into `edge`. Android is not in this phase; R10's legs trail by one, and the Team surface reaches
the app with core's own Team screens rather than a Rust-specific one.
### 24.0 The decisions this phase needed
| # | Decision |
|---|---|
| **D47** | **The uMod Clans plugin becomes OPTIONAL.** It moves out of `overlay.toml`'s `requires_plugins` and into phase 17's optional tier, which **amends R6**. The two systems share no data (the plugin never touches `ClanManager`), so a server running both splits its players across two unrelated clan systems. First-party clans are the only Teams source, and the admin surface warns when the plugin is loaded that its clans are not Teams. |
| **D48** | **A clan roster is shown to clan members and staff by default.** One fleet-wide operator setting with three rungs: `members` (the clan's own linked members, plus staff) · `signed_in` · `public`. It is §23's rule applied to a roster: a roster says who is in a clan and, inside its audience, who of them is on. **Widening it warns that it publishes online status too**, because core's `projectRoster` can withhold rows but not fields. |
| **D49** | **The clan activity feed carries membership only (founded, joined, left, removed), and every item is members-only.** Written with `visibility: 'members'` on every item. |
| **D50** | **Clan score and colour are in.** Score sorts the Clans tab and colour is shown as a swatch. **Deferred:** clan logos (images over the bridge into `ctx.uploads` is asset-bridge-shaped work that would roughly double the phase) and a Discord `/clan` command (stays in phase 16). |
| **D51** | **Protocol 6 is one board and five events.** A `clans` snapshot, re-sent on connect and on the 60-second cadence, plus `clan.created`, `clan.disbanded`, `clan.member.added`, `clan.member.left` and `clan.member.kicked`. The sidecar files by `type` (§8.1), so it only bumps its version. |
| **D52** | **`externalId` is `<serverId>:<clanId>:<createdMs>`.** The clan id alone is not an identity: the game's clan database is `clans.<version>.db` with the version hard-coded, so a Facepunch bump starts a fresh file whose ids restart at 1. Without the creation time, the new clan #1 would inherit the old clan #1's Team, its forum and its members-only history, and core would read the swap as a rename. |
| **D53** | **`getTeams` is `complete: true` only when every server's board is fresh**, supported and untruncated. Otherwise it answers `complete: false`, which core reads as "add and update, remove nothing". One server being offline must never archive its clans. When no server's board is fresh it refuses. |
| **D54** | **Leadership comes from the board, diffed one snapshot against the next**, and is published as `team.leader.added` / `team.leader.removed`. The game raises no promote or demote hook, so this is correct to within one cadence (about a minute). A leader is any member whose role has rank 1, and there may be several. The snapshot is shaped so phase 17 can sharpen it with the uMod plugin's `OnClanUpdate` rather than replace it. |
| **D55** | **The board is bounded, and its ceiling is the game's.** The game offers no "list every clan" call; the only listing is the clan leaderboard, which **the game clamps to 100 clans by score**. The org lead accepted that ceiling (2026-09-23) over reading the game's private SQLite schema directly. A server at 100 clans or more, or whose board would not fit on one line of the game link, reports `truncated: true` and the answer becomes partial: Teams there are added and updated but never removed. A **non-local clan backend** (Nexus) is refused with a reason rather than guessed at. |
| **D56** | **The pages are `/rust/clans/:externalId`, with a Clans tab on each server's page.** `pageUrlTemplate` substitutes only `{externalId}`, which rules out a route nested under the server. Three module slots are declared: `rust.clan.header` (core's `team.notify`), `rust.clan.detail` (`team.activity`) and `rust.clan.forum` (`team.forum`), one per place, as `module-uo` does. |
| **D57** | **Linking or unlinking an account calls `ctx.teams.reconcile`**, so a member's `userId` appears straight away instead of on core's next sweep. |
| **D58** | **The clan LIST is public; the roster is not.** Each clan's name, colour, score and member count names no player, so the Clans tab shows them to anyone (org lead, 2026-09-23). Rosters, leaders and who is online stay behind D48's audience. |
### 24.1 Facts the phase rests on
Read from the game's own assemblies (`Rust.Clans.Local.dll`, `Rust.Clans.dll` and `ClanManager`
in `Assembly-CSharp.dll`, decompiled off the Oxide rig), not from a catalogue:
- **`clan.enabled` defaults to `true`**, so every server has first-party clans unless an operator
turns them off. `ClanManager.ServerInstance.Backend` is null until the backend initialises.
- **The backend API has no enumerate-all call.** `IClanBackend` offers `Get`, `TryGet`,
`GetByMember`, `Create`, `ListInvitations` and `GetLeaderboard`. `GetLeaderboard(limit)` runs
`ListTopClans`, which is `SELECT clan_id, name, score FROM clans WHERE deleted IS NULL ORDER BY
score DESC LIMIT ?` with the limit clamped to **10..100**. That clamp is D55's ceiling.
- **The local backend is synchronous under an async signature.** Every `ValueTask` it returns has
already completed, so the plugin can read results on the main thread without blocking. A
backend whose task has not completed is treated as unsupported rather than waited on.
- **A leader is any member whose role has `Rank == 1`, and several are allowed.** The game only
stops you removing the last one. **Clans cannot be renamed**: `IClan` has no `SetName`.
- **Hook sites.** `OnClanCreated(LocalClan, ulong)` fires in `LocalClanBackend.Create` after the
commit. `OnClanMemberAdded(long clanId, ulong steamId)` fires inside
`LocalClanDatabase.AcceptInvite` with a **bare id**, before the cached `LocalClan` has been
refreshed. `OnClanMemberLeft`, `OnClanMemberKicked` and `OnClanDisbanded` fire from `LocalClan`.
**The founder's own membership fires no `OnClanMemberAdded`**, so `clan.created` implies it.
- **The uMod Clans plugin raises a hook with the SAME name.** It calls `OnClanDisbanded(string tag,
List<ulong> members)`, the same name and arity as the game's `OnClanDisbanded(LocalClan, ulong)`.
The bridge declares the game's types precisely, so a call with the plugin's types does not match
it. This is checked on the rig, which runs the plugin.
### 24.2 What the plan builds
**The plugin (protocol 6).** The `clans` board carries, per clan: `clanId`, `createdMs`, `name`,
`color` (`#rrggbb`), `score`, `maxMembers`, and `members` (each `steamId`, `rank`, `role`,
`joinedMs`, and `name` when the framework knows one). It also carries the board-level facts D53
and D55 need: `enabled`, `backend`, `supported`, `truncated`, and `umodClans`, which is whether the
optional plugin is loaded (D47's warning). A member's `LastSeen` is deliberately **not** sent: it
is presence (§23). The five events carry `clanId`, `createdMs`, the clan's `name` and the Steam
ids involved. A membership event also schedules a board re-send a couple of seconds later, so the
roster that follows it does not wait a minute.
**The sidecar.** `PROTOCOL_VERSION` becomes 6. Nothing else changes.
**The module.**
- **The store:** `rust_clans` and `rust_clan_members`, replaced per server from each board, plus
`rust_clan_boards`, which records per server when the board's `t` last advanced by the
**website's** clock. Freshness is judged by that, so clock skew between the game host and the
website cannot make a stale board look fresh.
- **The provider:** `getTeams`, `getTeamMembers`, `getTeamLeaders` and `projectRoster` (D48), plus
`pageUrlTemplate: '/rust/clans/{externalId}'`. `userId` comes from the module's own link table,
`online` from its own presence board, and `rankLabel` from the clan's role name.
- **The push:** each `clan.*` event is published to core (`team.created`, `team.disbanded`,
`team.member.added`, `team.member.removed`) and written to the Team feed as a members-only item
(D49), with a `dedupeKey` from the event's own id so a replay is a no-op. A board that changes
the set of clans asks core to reconcile, and a board that changes a leader publishes it (D54).
- **The pages:** the Clans tab (public list, D58) and `/rust/clans/:externalId`, whose roster is
projected by the same function core calls. The roster setting is a second section on the
existing **Rust visibility** admin page, which also lists any server running the uMod plugin.
- **The feed:** the five kinds are `staff` class in the public feed's allowlist. They reach the
public only through core's members-only Team feed.
**The docs.** This section as built, `rust-link/PROTOCOL.md` §12 (protocol 6), the R6 amendment
and `overlay.toml`'s requirement list.
### 24.3 Done when
The phase row's criterion: *the clan page is ours, core's contributions land in places we named,
and every slot empty still reads correctly.* Walked against the Oxide rig with real clans, plus a
Carbon load of the same plugin file.
### 24.4 As built, 2026-09-23
Rust-Plugins, Rust-Link and Module-Rust on `feat/phase-9-clans` (into `edge`), and this document.
**Core needed one change, and it is not a Teams change:** the org lead's rule that a site runs one
module (below), shipped as its own website PR with its own docs PR.
**The criterion is met on a live rig.** Against the Oxide rig with the protocol-6 sidecar inside the
container, and a core at the pinned sha running only this module, a rig-only helper plugin drove
the game's own clan backend with synthetic Steam ids:
- Two clans founded, two members added, one promoted, one leaving, one kicked, one disbanded.
Every hook fired (`rg.hooks`: created 2, added 3, left 1, kicked 1, colour 1, disbanded 1).
- Core created a Team per clan, synced the rosters with the right leaders, marked the leaver and
the kicked member departed, published the promotion from the board diff, and **archived the
disbanded clan's Team** on the next complete answer.
- The clan page renders signed out with the roster withheld and a sentence saying who may see it.
Core's activity contribution is in `rust.clan.detail`; the notify and forum slots are empty for a
visitor, and the page reads correctly without them.
- The roster gate agrees on both surfaces, the module's page and core's
`/public/teams/:slug/members`: hidden from an anonymous visitor and from a signed-in player
outside the clan, shown to that player once their link is to a member, and shown to a
moderator. Neither leaks a Steam id or an account id.
- Admin: a moderator's `PUT` is 403, an unknown rung is 400, and `public` opens the roster to an
anonymous visitor.
**On Carbon** (the other rig, Carbon 2.x): the byte-identical plugin compiled and loaded, and the
same helper fired every clan hook with the right counts.
### 24.5 What the rig found that the plan did not say
**1. Core's first answer is a refusal, and that is correct.** The `clan.created` event reached core
through the ingest cursor (every 5 s) before the first `clans` board reached it through the refresh
(every 30 s). Core's reconcile asked the provider and was **refused**: no current board. Twenty
seconds later the board arrived and the Teams were created. The alternative, answering from events
alone, is exactly what the envelope contract forbids.
**2. A Team's first feed line would always have been dropped.** Core writes an activity item only
for a Team it already holds, and a new clan's Team does not exist until core's next reconcile,
debounced by up to 30 seconds. "Founded the clan", the first line of every clan's feed, was
dropped on the live run (`teams activity push: dropped items`), as predicted. The module now
re-offers the last ten minutes of clan events on every board refresh. On the rig, all four
dropped lines landed on the next refresh, each once.
**3. Core clamps a `dedupeKey` to 40 characters, silently.** `MAX_DEDUPE = 40` in core's
activity model. A readable key long enough to be unique (server, clan, creation time, kind, player,
instant) is 80 or more characters, so it would be cut short into collisions, and a
re-offer would then have deduplicated *different* events into one. The key is a sha1, exactly 40.
`MODULE_API.md` does not state the limit; a kit reader would find it the same way.
**4. `occurredAt` is epoch milliseconds, not a `Date`.** Core reads `Number.isFinite(item.occurredAt)`
and replaces anything else with now. A `Date` would have stamped every re-offered line with the time
of the re-offer.
**5. Game ids restart with the database version, as §24.1 said, and the rig showed ids from 1.** The
fresh rig numbered its first clan `1`, which is why `createdMs` is in the identity.
**6. The ceiling is the game's, and it is 100.** `ListTopClans` clamps its limit to 10..100 (D55).
**7. One Team provider per site, and a load order that made it worse.** Core holds a single provider
and a second registration fails that module's **whole load**. Modules load alphabetically, so
installing this module beside `module-uo` would have taken `uo` down, not `rust`. The org lead's
rule, 2026-09-23: **a site runs one module.** Core's installer now refuses a module whose id
differs from the installed one (409) and accepts only upgrades (`MODULE_SYSTEM.md` §2.5, decision
35). This is its own website PR, not part of this phase.
**8. The uMod plugin's same-named hook does not reach the bridge, on either framework.** Raised in
both of the plugin's shapes, the bridge's own count for `OnClanDisbanded` stayed at the one real
disband, with nothing logged (PROTOCOL.md §12.4).
**9. A clan event must not move `last_seen`.** The ingest's `touchPlayer` moves it, which is right
for a frame that says a player was on. A kick is done *to* somebody who may be offline, and
`last_seen` is presence (§23). Clan events note names with an upsert that leaves `last_seen`
alone.
### 24.6 What is not proven here
- **A real player's name on a feed line.** The synthetic Steam ids have none, so the rig's lines read
"A player founded the clan". Named lines need a real player's account on the rig, which is a walk
to run beside the §12.5 identity walk.
- **Staff do not see the members-only feed.** Core's feed access is membership and grants; a
moderator reads the roster (D48 includes staff) but not the members-only lines. That is core's
rule rather than this module's, and it is written down rather than changed.
- **The 100-clan ceiling was not reached on a rig.** It is the game's own clamp, read from its
source, and the truncation path is covered by the suite.
- **A Nexus server was not available.** Its refusal is read from the game's backend selection
(`NexusServer.Started` chooses `nexus`), not walked.
### 24.7 Smaller things worth keeping
- **The rig helper** (`RgClanRig.cs`, rig-only, never shipped) is ninety lines against
`ClanManager.ServerInstance.Backend`: create, add (invite plus accept), role, kick, colour,
disband, list, and a fake uMod disband. It is the cheapest way to fire every clan hook without
players, on either framework.
- **swagger-autogen drops a backtick inside a parameter description** and reports success; the
module's fragment check caught it (`2 annotation(s) swagger-autogen could not parse`).
- `ListTopClans` returns a pooled list that the plugin does not return to the pool. The GC collects
it; it is noted here so nobody "fixes" it into a double free.
## 25. Phase 10 — notifications and engagement (plan of record, 2026-09-23)
R7's phase: the module says what happened in a Rust server in terms core's engagement engine can
act on — **triggers** (what an event is and the widest audience it may ever have), **audiences**
(named sets of people over this module's data), **seeds** (the bodies and the disabled rules that
use them) and **push streams** (the facet that lets a rule reach a phone). Nothing here decides who
is told: the module says what happened and who it is *about*, and core applies the rule, the
ceiling, the preference, the suppression list and the verification gate.
Three repositories and this document. The criterion — *the offline raid alert reaches the player
whose base it was, and nobody else* — could not be met on the wire as it stood, which is why
[Rust-Plugins][rp] and [Rust-Link][rl] are in a phase the table listed as `Module-Rust + docs`
(§25.1). All three into `edge`.
### 25.0 The decisions this phase needed
All from the org lead, 2026-09-23.
| # | Decision |
|---|---|
| **D59** | **"The player whose base it was" means everyone authorised on the building's tool cupboard.** Each linked one is sent their own alert — one emit per person, `ownerUserId` set, so the ceiling stays `owner` and "nobody else" holds per emit. The block's placer (`OwnerID`) is not the base's owner in any sense a Rust player would recognise: a base several clan members built alerts only whoever placed that wall, and a block placed by someone since kicked alerts *them*. An attacker who is authorised on the cupboard (a self-demolish, a teammate) raises no alert. |
| **D60** | **A raid is the destruction of a building block, a door, an external wall or gate, or the tool cupboard itself.** Today's emitter sees building blocks only, and a door is not one (§25.1), so the most common raid — two charges on a door — alerted nobody. |
| **D61** | **The alert fires whether or not the owner is online, and carries `ownerOnline`.** The seeded rule narrows with the condition `ownerOnline is false`, so "offline raid alert" is a rule an operator can widen by editing a condition rather than a code change. `ownerOnline` is the *recipient's* own state, since each emit has one recipient (D59). |
| **D62** | **Nothing reaches the game in this phase — which amends R7.** No announce leg and no post hook: R7 put both in v1, but the leg needs a chat verb the plugin does not have, and the hook would have nothing on Rust to mirror a post *into* (`module-uo` registers none either). The announce leg moves to phase 13, beside `rust.announce`, which needs the same verb; the post hook is dropped until something in game can hold a post; PopupNotifications moves to phase 17's optional tier. |
| **D63** | **A replayed event notifies only while it is still news, and the bound is per family.** After an outage the cursor (D9) replays hours of frames. A **broadcast** (server online/offline, wipe, leaderboard) older than **15 minutes** notifies nobody — "the server is up" is false three hours later. A **personal or staff** event (raid, clan, moderation) is kept for **24 hours** — "your base was raided at 03:10" is still true and still wanted. Every emit carries its true `occurredAt`, and a `dedupeKey` built from the server and the sidecar's own event id, so a replay of the same batch is a no-op in core's outbox. |
| **D64** | **Four changes to §10's catalogue.** `rust.clan.member.added` is **dropped** — core already fires `team.member.joined` (and `team.leadership.changed`) for our clans through phase 9's Team sync, so a second trigger double-notifies. `rust.kit.entitled` is **deferred to phase 13**, where the grant that emits it lands. `rust.login.denied` is **a deferred read** (§16.5): an attempt with no approval within 60 seconds, staff ceiling, **no address in the payload**. `rust.leaderboard.topped` is **the kills leader of the current wipe changing to somebody new**, never on first sight, once per server per leader. |
| **D65** | **Three families get a push-stream facet: the raid alert, server online/offline, and wipe started.** Core delivers engagement push only to devices subscribed to a stream whose id *is* the trigger id (§25.1), so a trigger without the facet can never reach a phone however its rule is set. Clan and moderation stay email and in-app. |
| **D66** | **The raid alert never names the raider.** The payload carries what was destroyed, the grid and the time. A raider's identity is gameplay intelligence the game itself does not hand the victim, and the org lead's lowest-blast-area rule (§23) applies: a variable that is not declared cannot be interpolated by any template an operator writes. |
| **D67** | **A structure with no tool cupboard alerts nobody.** No placer fallback: a cupboard-less structure is usually decaying or abandoned, and D59's whole point is that the placer is not the owner. |
| **D68** | **"Online" means the plugin is connected now, and that fixes the public page too.** Folded into this phase (§25.3). |
### 25.1 Facts the phase rests on
**From the game** (`Assembly-CSharp.dll` off the rig, decompiled — the build phase 9 pulled):
- **A door is not a `BuildingBlock`.** `Door : AnimatedBuildingBlock : StabilityEntity`, a sibling of
`BuildingBlock : StabilityEntity`, so `entity as BuildingBlock` — the protocol-2 emitter's filter —
has never seen a door. External walls are `SimpleBuildingBlock : StabilityEntity`; external gates
are `Door`s. The cupboard is `BuildingPrivlidge : StorageContainer`.
- **`authorizedPlayers` is a `HashSet<ulong>`** on current Rust (it was a `List<PlayerNameID>` in
older builds, which is what most public raid-alert plugins still read). **`recentGroupMembers` is
not authorisation**: it counts code-lock users toward group upkeep and prunes on a timer, and it
must not be read as "lives here".
- **`DecayEntity.GetBuildingPrivilege()`** resolves through the entity's building
(`GetDominatingBuildingPrivilege`). An entity with no building falls back to `BaseEntity`'s, which
is a `Vis.Entities` sphere query on the main thread. That is acceptable at the rate structures are
destroyed — tens per raid, not per frame — and it is the reason the lookup happens only after the
cheap filters (a real player did it; the entity is one of D60's four).
- **`OnEntityDeath` runs before the entity is destroyed**, so a cupboard being destroyed can still
report its own authorisation list.
**From core** (`website`, `main`):
- **There is no `self` ceiling.** The lattice is `everyone`, `authenticated`, `subscribers`,
`members`, `staff`, `admin`, `owner`. §10 gave `rust.player.linked` and `rust.kit.entitled` a
`self` ceiling core would refuse at boot; `owner` with the user as `ownerUserId` is the value that
exists and means the same thing here.
- **Engagement push reaches a device only through a stream of the same id.** `pushChannel.deliver`
calls `publishToUsers(row.trigger_id, …)`, which selects endpoints subscribed to *that stream*.
Hence D65.
- **The cooldown is per (rule, user, subject, channel).** The raid alert's subject is therefore the
building (its cupboard's id): one alert per base per cooldown, however many walls fall.
- **`dedupeKey` is UNIQUE in the outbox**, so an emit replayed with the same key is a no-op. Core does
**not** bound a stale `occurredAt`; that is D63, on this side.
- **Core already notifies Team members on `team.member.joined` and `team.leadership.changed`.** Hence
D64's first change.
- **`MODULE_API.md` says a module ships its own `engagement-triggers.json`**; core never reads it.
It is a freeze whose diff is the review signal, like `routes.manifest.json`. `module-uo` ships
none. This module ships one, gated in CI the way `routeManifest` is (line endings normalised —
see the engagement workstream's CRLF lesson), and phase 19 carries the gap back to the kit.
**From this module:**
- **The sidecar keeps its last `server.hello` board after the game disconnects**, and the refresh
reads only `/boards`. So a game that hangs, or whose bridge plugin is unloaded, while the sidecar
stays up has read as *online* on the public page since phase 4. In the in-container deployment
(§22, R20) a crash takes the sidecar with it and the page is right; a hang or an unload is not.
`/health` already answers "is the plugin connected" and nothing asked it. D68.
### 25.2 The catalogue as built
**Triggers.** Every one declares `subjectKey`, and every variable an `example`.
| Trigger | Source | `ceiling` / default audience | Subject | Push facet |
|---|---|---|---|---|
| `rust.base.destroyed` | `entity.destroyed` (protocol 7), one emit per linked authorised user | `owner` / `owner` | `building` | yes — personal, needs a linked account |
| `rust.wipe.started` | `server.wipe` | `everyone` / `subscribers` | `serverId` | yes |
| `rust.server.online` | the refresh, transition to connected (D68) | `everyone` / `subscribers` | `serverId` | yes |
| `rust.server.offline` | the refresh, transition away from connected | `everyone` / `subscribers` | `serverId` | yes |
| `rust.leaderboard.topped` | the wipe's kills leader changes (D64) | `everyone` / `subscribers` | `serverId` | — |
| `rust.player.linked` | the link confirm route (R1) | `owner` / `owner` | `steamId` | — |
| `rust.clan.member.left` / `.kicked` | `clan.member.left` / `.kicked` | `members` / `members` | `clan` | — |
| `rust.clan.disbanded` | `clan.disbanded` | `members` / `members` | `clan` | — |
| `rust.player.reported` | `player.reported` | `staff` / `staff` | `steamId` (the target) | — |
| `rust.player.banned` / `.unbanned` | `player.banned` / `.unbanned` | `staff` / `staff` | `steamId` | — |
| `rust.login.denied` | a `player.login.attempt` with no `player.approved` within 60 s (D64) | `staff` / `staff` | `steamId` | — |
A clan trigger's recipients are the clan's linked members at the moment of the event, carried as
`recipientUserIds` (the mechanism `module-uo` and core's Team fan-out use), because "the clan this
event was about" differs every firing and cannot be a saved audience. For a disband that is the
roster *before* the disband is applied.
**No variable carries an IP address, a Steam id other than the subject's own, or — for the raid
alert — the attacker (D66).** A ban's `reason` and a report's text are staff-ceilinged.
**Audiences** — user ids and nothing else; one that fails resolves to nobody (§10, §11.5).
| Audience | Resolves to | `ceiling` |
|---|---|---|
| `rust.clan.members` | a clan's linked members (param: the clan's `externalId`) | `members` |
| `rust.server.players` | linked accounts ever seen on a server (param: `serverId`) | `authenticated` |
| `rust.wipe.participants` | linked accounts seen on a server's **current** wipe (param: `serverId`) | `authenticated` |
§10 described the second and third as the same set ("seen this wipe" and "played the current wipe");
they are separated as all-time against this wipe, which is the distinction an operator can use.
**Seeds.** Every rule disabled (core enforces it). One **group key per family** — `raid-v1`,
`wipe-v1`, `server-v1`, `leaderboard-v1`, `account-v1`, `clans-v1`, `moderation-v1` — so a rule
added to a family later can take a new key without the others. Bespoke bodies only where the message
has something to say that core's structural projection cannot: **the raid alert** and **the wipe**,
email and in-app each. Everything else points at core's `notify.event` / `inapp.event`.
The raid rule: audience `owner`, `ownerOnline is false`, email + in-app + push, cooldown 30 minutes
per building. The offline rule: `delay_seconds` 300 with `cancel_on: ['rust.server.online']`, so a
plugin reload or a quick restart mails nobody.
### 25.3 What the plan builds
**The plugin (protocol 7).** `entity.destroyed` widens to D60's four entity kinds and gains
`buildingId` (the cupboard's net id) and `authorized` — each `{ steamId, online }`, **bounded at 64**
with `authorizedTruncated` when cut — both absent when there is no cupboard (D67). `ownerId`, `prefab`,
`grid` and the attacker fields stay as they were: the staff feed still reads them, and the module
decides what an alert may carry. `overlay.toml`'s protocol moves to 7.
**The sidecar.** `PROTOCOL_VERSION` becomes 7. The frame is stored and served as it arrives (§8.1),
so nothing else changes.
**The module.**
- **`server/engagement/`** — `triggers.js`, `audiences.js`, `streams.js`, `seeds.js` (the
declarations, one file each) and `emit.js`, the fan-out: one function per trigger, each wrapped so
a bad frame cannot stop ingest, each applying D63's bound before it emits. Registered from
`index.js`, which drops the comment calling these "deliberately absent".
- **Ingest** calls the fan-out after an event is written, inside the same per-item guard.
- **The refresh** asks `/health` beside `/boards` and records `connected`; `online` becomes
"the plugin is connected now" (D68). Transitions are tracked in memory, and a first sight is never
a transition, so a website restart announces nothing.
- **The leaderboard** leader is read after each ingest tick from the wipe's stats, with the same
first-sight rule.
- **The login-denied sweep** runs on the prune cadence as a query over `rust_events` — attempts
older than 60 s and younger than D63's 24 h with no approval for the same Steam id after them —
keyed on the attempt's event id. A query rather than an in-memory timer, so a restart loses
nothing and re-running it is a no-op.
- **`engagement-triggers.json`**, generated from the registrations and checked in CI.
**The docs.** This section as built, `rust-link/PROTOCOL.md` §13 (protocol 7), R7's amendment, §10's
corrections, and the phase row.
### 25.4 Done when
The row's criterion: **the offline raid alert reaches the player whose base it was, and nobody
else.** Walked on the Oxide rig with a cupboard authorising two linked accounts, a third linked
account that is not authorised, and a raid made by a fourth. Both authorised accounts get one alert
each. The third gets nothing, and so does the raider. Then the same plugin file runs on Carbon.
The rig has one Steam account, not four (§12.5), so the walk uses phase 9's method: a rig-only
helper that authorises synthetic Steam ids on a real cupboard and destroys a real door with a
spawned, non-NPC player as the initiator, so the real `OnEntityDeath` path runs. If the helper cannot
make the hook see a real player, that is written down as a walk still to run, not claimed.
### 25.5 As built, 2026-09-23
**Rust-Plugins (protocol 7).** `entity.destroyed` covers the four structure kinds and carries
`structure`, `buildingId` and `authorized` (PROTOCOL.md §13). `clan.disbanded` carries `members`
(§25.6). `attackerId` comes from `userID` (§25.6). The `structures` tally still counts building
blocks only.
**Rust-Link.** `PROTOCOL_VERSION` 7, and no other change.
**Module-Rust.** `server/engagement/` holds five files:
- **The declarations:** `triggers.js`, `streams.js`, `audiences.js` and `seeds.js`. That is 13
triggers, 4 streams, 3 audiences, 4 bodies (raid and wipe, email and in-app) and 13 disabled rules
in 7 groups.
- **The fan-out:** `emit.js`.
It is wired in three places:
- ingest calls `onEvent` for each stored frame, **before** it is applied, and `checkLeader` after a
batch;
- the refresh calls `serverObserved`;
- a new one-minute `sweep` timer runs the login-denied query.
The link confirm route calls `linked` on a **new** link only.
The refresh asks `/health` beside `/boards` (D68). `putState` gained a `seen` flag, so a stale board
no longer moves `last_seen_at`. `engagement-triggers.json` is the committed freeze of all of it,
checked in CI by `check:engagement` with line endings normalised. The check was verified by breaking
it both ways: a changed label fails it; a CRLF-only change passes. 234 server tests and 41 client
tests are green.
**The walk.** It ran against real core on `main` (MODULE_API 1.10.0) with a fresh database, the
protocol-7 sidecar inside the Oxide rig's container, and a rig-only helper (`RgRaidRig.cs`, never
shipped). The helper builds a real cupboard and door sharing a building id, authorises synthetic
Steam ids on the cupboard, and kills the target with a spawned, non-NPC player as the initiator, so
the real `OnEntityDeath` path runs.
- **Core accepted the whole set at boot:** 13 triggers, 4 streams, 3 audiences, 4 bodies and 13
rules in 7 groups, every rule disabled.
- **The criterion.** A door was raided in a base whose cupboard authorised owner1 and owner2
(both linked) and one unlinked id. The bystander was linked and not authorised; the raider was
linked. owner1 and owner2 each got **one** in-app alert: "Your base is being raided — A door was
destroyed in S16 on Oxide rig." The bystander, the raider and the unlinked id got nothing. Email
and push stayed quiet, because both default off until a person opts in.
- **Self-demolish** (the attacker authorised on the cupboard): no alert.
- **The cupboard itself destroyed:** owner1 was alerted, "A tool cupboard was destroyed in R3".
- **A door with no cupboard (D67):** the frame arrived without `buildingId`/`authorized`; no alert.
- **D68.** `oxide.unload RunicGateway` with the sidecar still up: within one refresh the module
read the server offline, with 0 players and `last_seen` frozen. Loading the plugin back raised
`rust.server.online`. The pending `rust.server.offline` notices, delayed five minutes, were
**cancelled** by it (`cancel_on`), twice.
- **Carbon 2.0.259:** the same plugin file compiled and loaded at protocol 7. The helper's door and
cupboard raids dispatched `OnEntityDeath` to the bridge (`rg.hooks`), and the new cupboard
lookup ran without an exception.
### 25.6 What the rig found that the plan did not say
- **Every generic notice said "A server came online", never which one.** Core's structural
projection fills `title` and `intro` from a trigger's label and description when the payload has
none. On a multi-server site that is a notice nobody can act on. Core's rule is "the payload wins,
the projection fills gaps", so every trigger now declares `title` and `intro`, and the emitter
writes the sentence. The walk's next transition read "Oxide rig is online — Oxide rig is back up
and talking to the website." No test could have seen this: every test asserted the payload, and
the defect was in what core did with a payload that was right.
- **`attackerId` arrived `null`.** `UserIDString` is set only in `PlayerInit` (a connection), `Load`
(a sleeper) and for engine bots, so a player another plugin spawns has none. Every real raider
has one, so this is the rig's artefact. But D59's self-demolish exclusion rests on the field, so
the plugin now derives it from `userID`, which is right for every player.
- **A disband's roster can be gone before the disband is read.** `markGone` deletes the members,
and the `clans` board is re-sent seconds after the event, so the refresh can apply it before
ingest reaches the frame. After an outage it always does. `clan.disbanded` now carries the roster
(PROTOCOL.md §13.2): the game walks `Members` to drop each membership and never empties the list.
The fan-out runs before a frame is applied as well, for the left and kicked cases.
- **`subscribers` is everyone, for in-app.** In-app defaults to `instant`, and core's `subscribers`
counts every active user with no in-app preference row as subscribed. So the walk's
"server online" reached all five users, not only walkadmin, who had opted in. That is core's
semantics, and `module-uo`'s broadcasts inherit it too. It is recorded rather than worked around:
an operator enabling a seeded broadcast rule reaches every inbox.
- **The dedupe key is made from the event, not the sidecar's row id**, which refines D63's
wording. A sidecar whose database is replaced restarts its ids, and a key built on them would
swallow every new alert as a repeat of an old one.
### 25.7 What is not proven here
- **A real raid by a real player.** The rig has one Steam account (§12.5). The helper runs the real
hook with a spawned initiator, which is as close as a player-less rig gets.
- **The frame's contents on Carbon.** The Carbon rig has no sidecar in its container (phase 8 wired
only the Oxide one), so on Carbon the hook dispatch and the absence of an exception are proven,
but the frame was not read back. The code after the hook is game API, the same assembly on both
frameworks.
- **Email and push delivery.** Both default off. The walk proved the rule reaches the right people
in-app. Push needs a subscribed device, and phase 11 (Android leg C) is where a phone subscribes
to a Rust stream.
- **The clan, moderation, wipe, leaderboard and login-denied triggers on the rig.** The suite covers
each one's audience and the bounds on it. The walk exercised the raid alert (the criterion) and
online/offline (D68).
### 25.8 Smaller things worth keeping
- **A running sidecar binary cannot be overwritten** (the node answers `500`, "text file busy"). A
rename can replace it: upload as `.new`, move the old one aside, then move the new one in.
- **Every panel client call needs `Accept: application/json`.** Without it the panel answers `302` to
its login page, and a binary upload "succeeds" with the old file still in place.
- **The walk found a latent schema defect outside this phase** and did not fix it. It is raised with
the org lead separately: tables since phase 6 declare `DEFAULT CHARSET=utf8mb4` with no
collation, while the older ones inherit the database default, so the VARCHAR foreign keys only
form when the database's default collation happens to be `utf8mb4_uca1400_ai_ci`.
- A Python heredoc halved a backslash in a JavaScript string again; the Edit tool is the safe path
for anything with an apostrophe in it.
## 26. Phase 11 — Android leg C: a Rust notification on a phone (plan of record, 2026-09-23)
R10's third leg: the app half of phase 10. Almost nothing about it is Rust-specific, and that is
the first finding. The inbox (engagement phase 8) and the preferences screen (engagement phase 3)
are both drawn **from the wire**: every trigger phase 10 declared already appears as a row with its
own channels, and an inbox item pointing at `/rust/servers/<id>` already opens the native server
screen (phase 5 put that path in the app's web-path table). The phase is the three places where the
app still assumed the site it talks to is a UO shard, plus the relay walk nobody has run yet.
Two repositories: [Android-app][aa] into `edge`, and this document into `main`. **Module-Rust is not
expected to change**, and core is not: everything below is a client reading something the server
already says.
### 26.0 The decisions this phase needed
All from the org lead, 2026-09-23.
| # | Decision |
|---|---|
| **D69** | **"Has a linked game account" is asked of the site's own module.** The app uses the capabilities it already reads (D16): `rust` asks `GET /player/rust/links`, and `shard` asks `GET /player/shard/accounts` as it does today. A site with neither has no link. One module per site (§24.5) makes that one call, never a merge. **Switching a channel off is never gated.** The link gates only switching **push on** for a `requiresLinkedAccount` item, which is the one thing §11 of the Android plan gave the flag. Rejected: dropping the gate entirely (the web has none, and the server enforces none, so this was a fair option), and having core report `linked` per item (a contract member and a `MODULE_API` bump to answer a question the app can ask itself). |
| **D70** | **A tickle with a `notification:<id>` ref is titled from that inbox item, pulled over the authenticated API.** This is the wake-and-pull contract core's `pushChannel` states in as many words: the ref is there so the app can fetch the real item, and the relay never sees it. When the ref is null, the fetch fails, or the item is not on the page, the notification falls back to today's per-stream title. On the lock screen it shows only that fallback title. Rejected: titling from the catalogue's label (less specific, and it needs a cache that can go stale), and adding the four Rust ids to the app's fixed `PushStreams` list (it ties an app release to a module's trigger ids). |
| **D71** | **Two links that already have a native screen stop opening the browser.** `/player/rust` (the *account linked* notice) goes to `PLAYER_RUST`. `/rust/servers/<id>?tab=<tab>` (the *new leader* notice) opens the server on that tab, **only** when the query is exactly `tab=` and the value is a tab the app has. That is the rule `?run=` already follows for events. `?tab=clans` still goes to the browser, because the app has no clans tab. |
| **D72** | **The walk proves the relay hop on public `ntfy.sh`.** The org lead's instruction was to reuse whatever `module-uo` did first. It did nothing: the Android plan's §11 has listed *on-device delivery against a live ntfy* as M7's one open QA item since M7 landed, and no walk has closed it since. Core publishes only to a public HTTPS host (`pushDispatch.isAllowedEndpoint`), so a local relay on `127.0.0.1` is refused by design. What leaves the rig is `{ stream, ref }` on a random topic and nothing else, the same thing that leaves a production site. |
### 26.1 Facts the phase rests on
**From the app** (`Android-app`, `edge`):
- **`NotificationSettingsViewModel` decides "linked" by calling `PlayerShardRepository.accounts()`**,
which is `module-uo`'s `/player/shard/accounts`. On a Rust site that route does not exist, the call
fails, and a failure reads as *not linked*. The screen then disables the **whole row** of every
personal item. The raid alert can't be switched on or off in any channel, including in-app, which
the link has nothing to do with. The criterion's second half fails on this alone.
- **`PushNotifier` titles every tickle from `PushStreams.titleRes`**: eight fixed ids from M7, all
UO or core, and "New notification" for anything else. A raid alert arrives saying nothing.
- **`PushService` handles tickles inside `collectLatest`.** A second tickle cancels whatever the
handler for the first was still doing. That was harmless while the handler did not suspend. A pull
(D70) suspends, so each one must run in its own job, or a burst (two authorised owners' alerts, a
wipe and a restart) loses all but the last.
- **Tapping a tickle with a `notification:*` ref opens the inbox** (`Routes.forTickle`, engagement
phase 8), and the inbox opens the item's link natively when `resolveWebPath` knows it. Nothing
here changes that. D71 only widens what `resolveWebPath` knows.
- **The web page's tabs are `feed`, `leaderboard`, `online`, `wipes` and `clans`. The app has the
first four.**
**From core** (`website`, `main`):
- **There is no single-item inbox read.** The inbox is a keyset-paged list, so the pull reads the
first page and looks for the id. The item has just been written, so it is on that page. If it is
not, D70's fallback applies.
- **A GET of the inbox marks nothing read.** The pull leaves the badge as it was.
- **`requiresLinkedAccount` is not enforced anywhere on the server**, and the web settings screen
has no gate. The app's gate is a courtesy, not a boundary, and D69 keeps it one: what decides who
is alerted is phase 10's recipient computation (D59).
- **Push defaults off for engagement rules until a person opts in**, which §25.5 observed on the
rig. The criterion's first half therefore needs the app's toggle to work before anything can
arrive.
### 26.2 What the plan builds
**Android-app** (`feat/rust-phase-11-notifications`, from `edge`):
- **The link check (D69).** A small `LinkedAccountChecker` over `SiteCapabilitiesRepository`,
`PlayerRustRepository` and `PlayerShardRepository`, pure enough to test without Compose.
`itemSelectable` becomes a question about **one channel and one direction**: every channel is
live, and a push-on for a `requiresLinkedAccount` item is refused with the existing hint when
there is no link.
- **The pull (D70).** `PushService` launches each tickle in its own job on the service scope rather
than inside `collectLatest`. `PushNotifier` gains a suspend path: parse the `notification:<id>`
ref, read the first inbox page under a short timeout, and post with the item's title and body, or
with the per-stream title when anything is missing. `VISIBILITY_PRIVATE` with a public version
carrying only the fallback title. The ref parser is prefix-exact, the same rule as `forTickle`.
- **The two links (D71).** `resolveWebPath` learns `/player/rust`, and learns `tab` as the second
query key it honours, on the Rust server path only. `RUST_SERVER` gains an optional `tab`
argument, and the server screen opens on it.
- **Tests** for each of the three, including the one that matters most: a Rust site with a link
lets push be switched on for the raid alert, and a site with no link still lets every channel be
switched off.
**docs:** this section as built, the phase row, and the Android plan's M16 and §11. §11 had
said that nothing is fetched to show a notification, and it has to stop saying so.
### 26.3 Done when
The row's criterion: **a Rust notification arrives on a phone and can be switched off there.**
Walked on an emulator against real core on `main` with this module installed, and the Oxide rig's
sidecar behind it:
1. On a Rust site, the app's settings let a linked player switch the raid alert's push on. The same
screen with no link leaves push refused and every other channel live.
2. The device registers an endpoint on `ntfy.sh` (D72). A raid made with phase 10's rig helper
(`RgRaidRig.cs`) is delivered as a **system notification titled from the item**. It shows the
fallback title on the lock screen and opens the inbox when tapped. The inbox row opens the server
natively.
3. The player switches the raid alert's push off in the app. A second raid produces **no** system
notification, while the in-app row still arrives if in-app is still on.
4. A UO site is unchanged: its personal rows still gate on `/player/shard/accounts`.
If the relay hop cannot be made on `ntfy.sh`, it is written down as a walk still to run, and the
rest is proven with the tickle injected by `adb`, the extras `PushNotifier` builds.
### 26.4 As built, 2026-09-23
**Android-app** (`feat/rust-phase-11-notifications`, into `edge`):
- **`LinkedAccountRepository`** asks `/player/rust/links` on a `rust` site and
`/player/shard/accounts` on a `shard` site. A host that has never answered gets both asked, and a
host that named neither module gets neither.
- **`pushNeedsLink` and `canSetMode`** replace `itemSelectable`. The row stays live, the hint names
the push control, and a held push switch that is already on can still be turned off.
- **`PushContentResolver`** pulls the first inbox page under a five-second timeout. It titles only
from a row whose id matches the ref **and** whose trigger matches the tickle's stream. The
notification is `VISIBILITY_PRIVATE`, with a public version carrying the generic title.
`PushService` launches each tickle in its own job.
- **`resolveWebPath`** learns `/player/rust`, and `tab` on a Rust server path. `RustTab` gained its
wire names, and `RUST_SERVER` an optional `tab` argument.
The suite is **672 tests, 0 failures**, up from 657, with `lintDebug` and `assembleDebug` green.
**Module-Rust and core: no change**, as planned. This is the fifth Rust phase in a row that core did
not need to change for.
**The walk.** Real core on `main` with this module installed, the protocol-7 sidecar inside the Oxide
rig's container, the `s22_ultra` emulator, and `NTFY_PUBLIC_URL`/`NTFY_BASE_URL`/`NTFY_ALLOWED_ORIGINS`
all set to `https://ntfy.sh` (D72).
1. **Linked (raidowner1):** the raid alert's push switch was live. Switching it on registered
`https://ntfy.sh/<topic>` as the device's endpoint and stored the subscription. With no link
(walkadmin), push was held with the hint, while email and in-app stayed live. In-app was switched
off, stored as `off`, and switched back on.
2. **Raid, then the phone:** `rgraid.door` on the live rig, attacker `…09`, authorising raidowner1's
Steam id. Core dispatched to **two** channels (in-app and push). **Nineteen seconds** after the
console command, the phone posted *"Your base is being raided — A door was destroyed in P16 on
Oxide rig."* It was `vis=PRIVATE`, with a public version. The tap opened the inbox, and the row
opened the Oxide rig's server screen natively.
3. **Switched off on the phone:** push off in the app. The server then held no subscription and no
device, since nothing else had push on. The next raid dispatched to **one** channel. The inbox
row arrived (*"A door was destroyed in R15"*), no system notification was posted, and the push
service had stopped.
4. **D71:** rows seeded with the module's own URLs. `?tab=leaderboard` opened on the Leaderboard tab,
`/player/rust` opened *My Rust account*, and `?tab=clans` went to a Custom Tab.
**The criterion is met: a Rust notification arrived on a phone and was switched off there.**
### 26.5 What the walk found
Nothing the suite had missed. That's the first leg of this workstream to say so, and the reason is
worth keeping: every defect this phase fixed was found **by reading** before any code was written
(§26.1). The UO-shaped link check was visible in one line of the view model. The generic title was
visible in `PushStreams`. The cancelled pull was a property of `collectLatest` that nobody had needed
to think about until a handler suspended.
Two things are worth writing down:
- **The first real relay hop on any site went cleanly.** M7 built the distributor in 2026-07 and
nobody had delivered a tickle through a live ntfy to a device since. It worked the first time,
including registration against core's endpoint validator and the unregister when the last push
preference went off.
- **Two channels, then one, is the proof that "off" was honoured by the server, not by the phone.**
The app never saw the second raid's push: core did not enqueue it. A phone that merely stopped
showing notifications would have looked the same on the screen and different in the log.
### 26.6 What is not proven here
- **A UO site, walked.** No UO core was running. The UO branch is the same call it always was, and
`aUoSiteStillAsksTheShardModuleAndNeverTheRustOne` pins it.
- **The lock screen, seen.** The emulator has no screen lock. The public version was read back from
`dumpsys notification`, not looked at.
- **A burst.** Each tickle now runs in its own job, which is what a burst needs, but the walk sent
one raid at a time.
- **A real raid by a real player**, for §25.7's reason: the rig has one Steam account.
---
## 27. Phase 12 — the leases (plan of record, 2026-09-23)
The first phase where an event changes a Rust server. [kit][kit] ch. 5 orders it before the actions,
and the reason still holds: a lease borrows a value that already exists, so its baseline costs
nothing. It proves the whole command path (correlation, the deadline, the drift check, reconcile)
before anything hands out loot.
Three repositories: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr], all into `edge`, as
**protocol 8**. This document goes into `main`. **Core is not expected to change and
`MODULE_API` does not move**. D73 is what makes that true.
**Three things §9 said turned out to be wrong**, and the facts below are what the phase is built on
instead:
- The three rate leases it leads with have **no convar to set** in vanilla Rust.
- `rust.group.membership` became a group-wide permission (D75).
- *"Caps are per run, so a cap is per server"* describes a fan-out core does not perform: every
scheduled run has the empty scope.
### 27.0 The decisions this phase needed
All from the org lead, 2026-09-23.
| # | Decision |
|---|---|
| **D73** | **The target names the server.** Every Rust lease is *targeted*, and the target begins with the server id: `<serverId>` for a single value, and `<serverId>/<…>` for a family. The ledger's ref then becomes `rust.decay.scale#srv-a`, so two servers never collide and one server has exactly one holder. That is the two-events-one-target rule at the granularity Rust actually has. No core change and no `MODULE_API` bump are needed. An event across six servers is six steps. Rejected: changing `core.lease` to forward `run.scope` and fold it into the ref. That is a 1.11.0 bump, a website PR and a kit re-pin, and it still does nothing for a scheduled run, whose scope is always `''`. |
| **D74** | **Game convars only.** The catalogue holds the convars vanilla Rust actually has: `decay.scale`, the `*.population` values and the `spawn.*` scalars, each verified live before it is advertised. **There is no gather, craft or smelt lease**, so §H's canonical *"double gather weekend"* cannot be expressed this phase. Rejected: multipliers owned by the plugin, implemented through `OnDispenserGather` and blueprint times. That makes the bridge a gameplay mod, and the value would exist only while our plugin is loaded. |
| **D75** | **The "weekend VIP" lease is a group-wide permission.** `rust.group.permission`, a `bool` with the target `<serverId>/<group>/<permission>`. *"Group `default` holds `kits.vip` until Monday"* makes everybody VIP for the weekend, which is the event-shaped use. Rewarding a named winner stays in phase 13, where participants are known at run time and not at authoring time. Rejected: per-player membership (the target must be fixed when the step is written, so it only serves a player chosen in advance), and building both. |
| **D76** | **Unattended event writes have their own switch on the plugin, off by default.** `EventsEnabled` goes in the plugin's config, editable from the site through R18. This is UO's `Bridge.EventsEnabled` rule: a scheduled change at 04:00 is a different consent from staff moderation or a permission sync. **It gates `lease.apply` only.** Listing and releasing always work, so switching events off never strands a hold. |
| **D77** | **A lease may be held for up to seven days.** `maxDurationMs` is 604 800 000 on every Rust lease, and the plugin holds the same ceiling independently. It **refuses** a longer hold and never clamps it (link v6 §8.2's rule). A week-long event fits in one hold. The cost is accepted: if the site goes away, the world stays changed for up to a week before the game's own deadline restores it. |
| **D78** | **Only the option sources this phase's leases read.** Those are the three targets' sources, and nothing else. Kits, prefabs, monuments and zones arrive with the verbs that consume them in phases 13 and 14, so no source ships without a reader. |
| **D79** | **No budget dimension is declared this phase.** No lease spends one: `core.lease` deliberately carries no cost. A dimension with nothing to spend it would still show on the operator's cap screen as a dial that does nothing. Each of §9's four is declared beside the verb that costs it, in phase 13. |
### 27.1 Facts the phase rests on
**From the Oxide rig** (`rust-oxide`, 2026-09-23: `find` on the console, and `serverauto.cfg` read back):
- **No vanilla convar sets gather, craft or smelt rates.** `find gather` returns only
`gamemodesoftcore.gather_rate`, which applies only in Softcore mode. `find craft` returns
`craft.instant`, which is a `bool`. `find smelt` and `find oven` return nothing that is a rate.
§9's `rust.rate.gather` / `.craft` / `.smelt` rows have nothing to set. `rust.time.night` is also
unverified: `server.cycletime` exists, but its description was machine-generated (`(Generated)`),
and a lease on day length is out until a walk shows what it actually does.
- **What exists and is shaped like a lease:** `decay.scale` ("1.0 = normal, 0.0 = no decay"); about
twenty-five `*.population` values (animals per km², and vehicles per server); and
`spawn.min_rate` / `max_rate` / `min_density` / `max_density`.
- **None of those is a `Saved` convar.** `serverauto.cfg` holds the `Saved` set (for example
`decay.upgrade_*` and `env.redmoon`), and none of the candidates appears in it. **A restart is
therefore a free restore** for every convar lease, as it is for UO's config lease (link v6 §8.3).
The plugin enforces this rather than trusting it: it refuses to advertise any allowlisted convar
whose `Command.Saved` is true, because a hold on one of those would be written to disk and survive
the restart that was supposed to end it.
- **A group permission is not memory-only.** Both frameworks persist their permission store, so a
hold that is not restored survives a crash, a restart and a plugin reload. **This is the one lease
the plugin must remember itself.**
**From core** (`website`, `main`):
- **`core.lease` hands a module's callables only `{ target }`** (`read`), `(value, until, { target })`
(`apply`), `(baseline, { expected, target })` (`restore`) and `{ ref, target, payload }`
(`inForce`). The run's `scope` reaches actions in `dispatch.js`'s envelope but never reaches a
lease, and the ref is `<id>#<target>` with no scope in it. D73 is the answer.
- **Every scheduled run has the empty scope** (`eventRunner.js`, org lead 2026-09-02): *"A fan-out
across named scopes needs a registry of what a scope IS, which no phase owns yet."* Only an admin's
own start route takes a scope. §9's *"six servers is six budgets"* therefore holds only for runs
started by hand.
- **`core.lease` declares no `budgetMs`, so it runs under the default 10 s**, and inside that its
`perform` makes two module round trips (`read`, then `apply`). This module's sidecar client waits
up to 12 s on each. So one slow read is enough for the dispatcher to classify the attempt as retry
while `perform` is still running. **To be checked at build, not assumed:** what a retried
`core.lease` does when its first attempt's reservation and apply land late. If that is a core
defect, it is raised and not fixed here (it applies to UO equally). The module's own half is in
its control: lease calls get a timeout short enough that `read` plus `apply` fit inside 10 s, and
a test asserts the sum.
**From the bridge** (`Rust-Plugins`, `edge`):
- **The permission mirror (protocol 4) reports anything it did not author as `foreign`**, and the
plugin emits `perm.drift` for writes it did not make itself. Unless it is taught otherwise, a
lease-held group permission would be reported to the site as a human's console grant. See §27.3.
### 27.2 The catalogue
What the plan will declare. **Every key is verified live before it ships**, and a key that applies
and reads back but changes nothing is dropped, not advertised (§9's rule).
| Lease | Type | Bounds | Target | Source |
|---|---|---|---|---|
| `rust.decay.scale` | `float` | 0 to 10 | `<serverId>` | `rust.options.servers` |
| `rust.population` | `float` | 0 to 50 | `<serverId>/<convar>` from the population allowlist | `rust.options.populations` |
| `rust.spawn.scalar` | `float` | 0 to 10 | `<serverId>/<convar>` from `spawn.{min,max}_{rate,density}` | `rust.options.spawnscalars` |
| `rust.group.permission` | `bool` | none | `<serverId>/<group>/<permission>` | `rust.options.grouppermissions` (searchable) |
- **The bounds are enforced twice**, as UO's are. Core checks the declaration's `min`/`max` when an
author saves, so a bad value is refused on a form and not by a step running unattended. The
plugin's own per-key bounds are what hold when the website is wrong.
- **Populations share one lease because they share a unit, not because they share a value.**
`metaldetectorsource.population` (1200 by default) does not fit the 0 to 50 bound and is left
out. So is any population the live walk cannot show taking effect.
- **An option source's values are whole targets.** A target source cannot depend on another field
of the same step, so `rust.options.populations` lists `srv-a/bear.population`, and not a server
followed by a population. Each source resolves every configured server's live answer and returns
`[]` for a server that does not answer, so one server being down never blanks the form (§9).
- **`maxDurationMs` is seven days on all four** (D77).
### 27.3 What the plan builds
**Rust-Plugins**: protocol 8, and `overlay.toml`'s protocol advanced to match.
- **`lease.list`, `lease.apply` and `lease.release`**, as UO's (link v6 §8.2):
- **`holdMs` is authoritative** and `untilMs` is for display.
- **Values cross as text** and are compared parsed.
- **A hold longer than seven days is refused.**
- **Release is compare-and-set.** A current value that is not what the lease applied answers
`lease.drifted` with that value, and the world is left alone.
- Convars are resolved through `ConsoleSystem.Index.Server.Find`, from an **allowlist in the
plugin**: the sidecar forwards and knows nothing. `Saved` keys are refused at load (§27.1).
- **`EventsEnabled`**, a new config key that defaults to `false` (D76). It gates `lease.apply`
alone, and its refusal says which switch to turn.
- **The deadline lives on the game.** Each hold arms a timer. When the timer passes, the plugin
restores the baseline (still compare-and-set) and emits `lease.expired` without being asked. The
website is never needed for the fail-safe.
- **A holds file under the framework's data directory** records each hold: baseline, applied value,
deadline, and the `bootId` it was taken under. That directory is live state, which R18's editor
never walks. What happens on load depends on how the plugin came to be loaded:
- **Plugin reload, same boot** (a config save from the site, a framework update): convar holds
are re-armed, because their values are still in the game's memory. A reload does not end an
event.
- **New boot**: convar holds are dropped, because the restart already restored them.
Group-permission holds are re-armed, and any whose deadline passed while the server was down
are restored at once.
- **The permission mirror learns about leases.** A `(group, permission)` pair held by a lease is
skipped by `perm.sync`'s diff while the hold is live. It is reported as `leased` and never as
`foreign`, and the plugin's own lease writes raise no `perm.drift`.
- **Carbon** needs nothing conditional. `ConsoleSystem` belongs to the game, and the permission
calls are the ones R19 already proved.
**Rust-Link**: protocol 8. `GET /lease`, `POST /lease` and `POST /lease/release`, forwarded with
`cmd` and `reqId` written over the caller's, as every protocol 4 and 5 route is. `lease.expired`
travels on the feed. As with UO, the website maps it to nothing, because core learns what happened
through `restore` and `inForce`.
**Module-Rust**:
- `registerEventLeases` with §27.2's four leases, and `registerEventOptionSources` with its four
sources. **No `registerEventBudgets`** (D79).
- Target parsing splits off the server id first. A target naming no configured server is refused
`retry: false`, because the second attempt carries the same params.
- `restore` maps `lease.drifted` to `{ ok: false, drifted: true, current }`, and maps a target that
no longer exists (a group deleted mid-hold) to a successful release. `inForce` reads the plugin's
holds, **never a comparison with `read()`** (MODULE_API §1.1).
- A lease-specific client timeout, with the arithmetic from §27.1 under test.
- No new route, and no swagger change expected.
**docs**: this section as built, the phase row, §9 corrected where §27.1 contradicts it, and
`rust-link/PROTOCOL.md` §14 for protocol 8.
### 27.4 Done when
The row's criterion, amended by D74 and D75: **a leased value is observed changing in the running
game and restored, per key; and `rust.group.permission` expires without core asking.** It is walked
on the Oxide rig against real core, with an event run authored in the admin UI:
1. **Each key's effect is observed in the game, not only read back.**
- A population: `spawn.report`'s target count for it moves.
- `decay.scale`: `decay.debug` on a rig with `decay.tick` lowered shows the damage scaled.
- A group permission: a player in the group passes `UserHasPermission`, and Kits' menu shows the
kit unlocked.
Each is then restored at teardown, and the ledger row reads `reverted`.
2. **The game's own deadline.** The run is started, and core is **stopped** before the hold ends.
The plugin restores the value at the deadline and emits `lease.expired`. On restart, core's
reconcile sees `held: false`.
3. **Drift.** A console change during a hold leaves the world alone at release, and the row reads
`drifted` with the current value beside it.
4. **Reload and restart.** A plugin reload mid-hold keeps the hold, and its deadline still fires. A
server restart mid-hold releases a convar hold and keeps a group-permission hold.
5. **The switch.** With `EventsEnabled` off, `apply` is refused with the reason, while a release of
an existing hold still succeeds.
6. **Carbon.** The same plugin gets a sidecar on the Carbon rig (`INSTALL_RIG.md`), and at least one
key from each family is walked there. If that cannot be done, this section says so.
### 27.5 As built, 2026-09-24
[Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr] on `feat/phase-12-leases`, into `edge`.
**Core is unchanged and `MODULE_API` did not move**, which D73 bought. Walked against real core on
`main` (a fresh `rustp12` database) with this module installed, on **both** rigs.
**The catalogue as shipped** is narrower than §27.2 planned, and each cut was made by the walk:
| Lease | Keys | Walked |
|---|---|---|
| `rust.decay.scale` | `decay.scale` | a lone twig foundation with `decay.tick` lowered on the rig lost 0.081 health per 32 s at 1, **0.404 at a lease of 5**, and 0.081 again after release |
| `rust.population` | **18** animal and vehicle populations | every one: the game's own target in `spawn.report` moved and came back (bear 18 → 45 → 18, and so on) |
| `rust.spawn.scalar` | `spawn.min_rate`, `spawn.min_density` only | `spawn.scalars`: the population rate 0.5 → 2 → 0.5, the density 0.5 → 1.5 → 0.5 |
| `rust.group.permission` | any registered permission on any group | a synthetic player in `default` passed `UserHasPermission` while the lease was held and failed it after |
- **The two `spawn.max_*` scalars are not lent.** The game interpolates between min and max by how
full the server is, so on an empty rig the effective values are the minimums and the maximums do
nothing that can be seen. §9's rule is that a key which cannot be seen doing something is not
advertised. A walk with players online is what would add them.
- **Every population is per square kilometre, vehicles included.** The game's own help text says
vehicles are "active on the server", but on the 9 km² rig a value of 3 is a target of 27.
**What the walk proved, against §27.4:**
1. **Each key's effect, observed**, as in the table. The first run held all four families through
core and tore them down at the end: every ledger row `reverted`, every value back.
2. **The game's own deadline, with the website gone.** A run took a 3-minute hold on a group
permission and on `decay.scale`, then core was **stopped**. At the deadline the plugin restored both
and emitted two `lease.expired` frames, which the sidecar stored. When core came back, its boot
reconcile asked about both holds, was told `held: false`, and marked both rows `orphaned` with the
module's reason.
3. **Drift.** `decay.scale 3` typed at the console during a hold. At teardown the row read `drifted`,
with *"the value is now "3" rather than what this run applied, so it was left alone"*, and the
world was left at 3.
4. **Reload and restart.** A plugin reload in the middle of three holds kept all three, deadlines
intact (`leases: 3 held`). A **server restart** in the middle of a convar hold and a permission
hold logged `leases: 1 held, 1 ended by a restart`: `decay.scale` came back at 1 and the permission
stayed granted. At teardown the permission was given back and the convar release found it
`already` restored. Both rows `reverted`.
5. **The switch.** With `EventsEnabled` off, an apply was refused with the switch named. It was
turned on **from the site's own config editor**, which is how an operator would do it (below).
6. **Carbon.** The same plugin file, with a sidecar installed in the Carbon rig's container the way
`INSTALL_RIG.md` describes. All 21 keys resolved there and none was `Saved`. Through core: bear
target 54, `decay.scale` 5 (0.47 per 32 s), `spawn.min_rate` 2, and a group permission
(`adminmodule.greet`, which Carbon registers itself) granted and then given back. All rows
`reverted`.
The permission mirror's side of §27.3 was walked too. A `perm.sync` sent during a hold listed the
pair under `leased` and not under `foreign`, and the site's wish was stored on the hold. At teardown
the pair stayed granted (`already true`), because the site had asked for it.
### 27.6 What the walk found that the plan did not say
**Two defects in this phase's own code, both fixed before merge:**
- **Every group-permission grant was a silent no-op.** `GrantGroupPermission(group, perm, this)`:
given an owner, Oxide first asks `PermissionExists(perm, owner)`, meaning *did this plugin register
the name*, and returns without a word when it did not (confirmed in the decompiled
`Oxide.Core.Libraries.Permission`). A lease only ever borrows another plugin's permission. The
plugin's read-back caught it (*"the store did not grant …"*), and the owner is now `null`.
- **`EventsEnabled` did not take effect when an operator changed it.** The site's editor saved the
file and answered `changed: true`, and the plugin went on refusing. It never re-reads its own
config, because it never reloads itself (§21.3). It is the first bridge setting an operator is
*meant* to change from the site, so a write to the bridge's own file now re-reads that one key.
The report says so: *"EventsEnabled took effect at once; the bridge's other settings apply on its
next reload."*
**Four findings outside this phase, raised and NOT fixed**:
- **The phase-7 permission mirror has the same owner bug.** `EnsureGroupPermission` and
`EnsureGrant` both call Oxide with `this` and count the grant without reading it back. On Oxide,
then, every site-authored grant of another plugin's permission has probably never landed, while the
report counted it as applied. It stayed hidden because every earlier walk used a permission that was
`unresolved` (Kits was not installed) and so never reached the call. The fix is one token in two
places plus a read-back. It needs a decision because it changes what the site has been told since
phase 7.
- **Core's cleanup restores `orphaned` lease rows, and that can overwrite a later run's world.** Run
2's rows were orphaned at core's boot reconcile, and run 2 was cancelled. Cleanup then tried to
move them back to `reverting`. That collided with run 3's live row for the same target on
`uq_evres_target` and failed on every sweep (13 times). Once run 3 released, the stale `restore()`
went through and **revoked the permission run 3 had deliberately left granted**. A lease's
compare-and-set cannot catch this when the later holder applied the same value. For a spawn,
reverting something that is gone is harmless. For a value, "no longer in force" plus a later
restore is a write of an old baseline over the present.
- **`core.lease` drops the module's `retry: false`.** Both of its failure returns rebuild the envelope
as `{ ok: false, error }`, so an `events-disabled` or `unresolved` refusal is retried three times.
The Carbon walk's step 27 did exactly that. Harmless, but it is noise and it makes
`retry: false` unreachable in a lease callable.
- **`core.lease` runs under the default 10 s with two module calls inside it.** The module fits by
giving lease calls a 4.5 s timeout (asserted in a test), which is below the sidecar's 10 s reply
timeout. A `budgetMs` on `core.lease` would remove the squeeze. §27.1 asked what a late-landing
retry does; the module's release-after-timeout (§14.7) covers the world side, and the question
stands for core.
### 27.7 What is not proven here
- **`spawn.max_rate` / `spawn.max_density`**, for want of players. They are left out, not guessed.
- **A real player seeing a kit unlock.** Kits is not installed on either rig and there is no second
client. The permission was observed through `UserHasPermission`, which is what Kits calls.
- **A crash, as opposed to a restart**, for the holds file. The same load path runs either way.
- **Walk 3's permission outcome as first observed.** It came out `false`. Walk 4 then showed the
intended behaviour (`already true`), and the cause turned out to be the core cleanup finding above,
not the plugin.
**Three of the four findings are filed against core** (2026-09-24): [website#205][w205] (cleanup
restores an orphaned lease row), [website#206][w206] (`core.lease` drops `retry: false`) and
[website#207][w207] (`core.lease`'s two calls under the default budget). The fourth, the phase-7
mirror's owner bug, belongs to this module and is fixed in phase 13a (D85).
## 28. Phase 13 — the actions (plan of record, 2026-09-24)
The phase where an event makes something that did not exist. The row named three verbs, one option
source and the reconcile watch. The decisions below made it two world verbs with NPCs, a monument
source, a participation tally kept by the game, the entitlement, and the announce action with its
leg. So it is **split in two**, the way Events phase 16 was (D80):
- **13a, the world**: the owner-bug fix, `rust.zone.open`, `rust.prefab.place`, the monument source,
the plugin's ownership registry, and `reconcile()` with the boot and wipe watch. Protocol 9.
- **13b, the rewards**: the participation tally, `rust.kit.entitle` with the kit source, and
`rust.announce` with the announce leg. Protocol 10. It builds on 13a's zones. **Its plan of record
is §29, written before its code**, the same way this section is.
Three repositories each time: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr], into
`edge`. This document goes into `main`. **Core is not expected to change and `MODULE_API` does not
move.**
### 28.0 The decisions this phase needed
All from the org lead, 2026-09-24. D80–D91 cover both halves. D92–D95 came from the rig probe in
§28.1 and apply to 13a.
| # | Decision |
|---|---|
| **D80** | **Phase 13 splits into 13a (the world) and 13b (the rewards)**, each with its own plan section, PRs and walk. 13a comes first because 13b's participation is counted inside 13a's zones. |
| **D81** | **The recipients of a reward are counted by the GAME, not the website.** `rust.participation.open` / `.collect` follow `module-uo`'s shape: the plugin keeps a per-run tally and `collect` reports it to core on the envelope. Rejected (my recommendation): the module derives the tally from the connect and disconnect events it already ingests. That needs no plugin change, but it cannot see presence in a zone or kills credited in one, and it is only as complete as the sidecar's raw window. |
| **D82** | **The tally's area is a zone when the step names one, and the whole server otherwise.** The zone is normally one the same run opened with `rust.zone.open`. It is fed by `OnEnterZone`/`OnExitZone` for zones in the tally only, so no other zone costs anything (R17's "subscribe selectively"). It is persisted, so a restart does not lose it. |
| **D83** | **The score is set per event: seconds present, kills, or both.** It is chosen on the `participation.open` step. (The org lead's own answer, replacing the three options offered.) |
| **D84** | **An earned entitlement is held by the site's permission mirror, in per-run rows.** It gets a table of its own keyed to the website user (D28) and unioned with admin grants into R2's desired set. A revert deletes only this run's rows, so the same kit granted by an admin or by another run stays. A wipe cannot lose it, because the site re-pushes. The cost is stated up front: **only a LINKED participant can receive one.** Unlinked participants are counted in the step's `detail` as missed. Rejected: the plugin grants the Steam id directly from a store of its own. That reaches unlinked players, but the site stops being the author of record. `rust_perm_grants` could not be reused as it stands: it is `UNIQUE (user_id, permission, scope)`, so an event's grant and an admin's grant of one kit are one row, and reverting the event's would delete the admin's. |
| **D85** | **The phase-7 owner bug is fixed inside this phase**, as its own commit in 13a's Rust-Plugins PR. `EnsureGrant` and `EnsureGroupPermission` pass a `null` owner and read the grant back, so a grant that did not land is reported and not counted (§27.6). This section says plainly what the site was told wrongly from phase 7 until now. |
| **D86** | **Both world verbs ship in 13a: `rust.prefab.place` and `rust.zone.open`.** No phase row had scheduled `zone.open`, although R17 moved it into the base catalogue. Rejected: deferring it to phase 14 with the map. |
| **D87** | **A location is a monument (plus an optional offset) or raw world coordinates**, one or the other on each step. |
| **D88** | **`prefab.place` may spawn crates and NPCs, and never vehicles.** Vehicles were rejected because a player can take one, and a revert would kill something a person may be sitting in. The allowlist lives in the plugin, and neither the sidecar nor the site can widen it. |
| **D89** | **NPCs are counted on their own budget dimension**, `rust.npcs`, apart from `rust.prefabs`, the way `module-uo` separates creatures from decor. An operator can then allow crates freely and cap fights. 13a declares `rust.prefabs`, `rust.npcs` and `rust.zone.minutes`; 13b declares `rust.grants` and `rust.announcements`. This is D79's "each is declared beside the verb that costs it". |
| **D90** | **`rust.announce` ships with the announce leg** (D62): one chat verb in the plugin serves both. |
| **D91** | **Phase 12's three core findings are filed**: website#205, #206 and #207. |
| **D92** | **Zones are re-created from the plugin's registry.** ZoneManager keeps temporary zones in memory only, and **erases every zone a plugin owns when that plugin unloads** (§28.1). The bridge keeps its zones in its own file and re-creates them in three cases: on its own load, on `OnPluginLoaded(ZoneManager)`, and after a restart. It does this within the same wipe only; a new wipe drops them all. A zone therefore lasts until the run reverts it. Rejected: reporting the zone lost and letting reconcile orphan it. |
| **D93** | **A monument is named by kind and instance, and resolved when the step runs.** The value is `<serverId>/<prefab short name>`, and `#n` picks the n-th of a kind that repeats (`srv-a/harbor_1#2`), numbered in a stable order. A step naming a unique monument therefore survives a wipe onto a new procedural map. A kind the new map does not have is refused `retry: false`, with the reason. The source lists what this wipe's map actually has. Rejected: any instance picked at random, and a pinned position (every saved event would break at the next wipe). |
| **D94** | **`EventsEnabled` gates the world verbs too.** It is one consent: *events may change this world unattended*. As with D76, revert, reconcile and listing always work, so switching events off never strands a crate, an NPC or a zone. |
| **D95** | **Per-step bounds: 25 crates, 20 NPCs.** A 50 m scatter, a monument offset of up to 150 m, a zone radius of 5 to 150 m, and raw coordinates that must lie on this map. They are **refused, never clamped** (link v6 §8.2's rule). They are plugin config that an operator may lower, and the site mirrors them so a dry run shows them. (The org lead chose these over the 10/10 I recommended.) |
### 28.1 Facts 13a rests on
**From the Oxide rig**, 2026-09-24, found with a probe plugin (`RgProbe13`, rig-only, never
shipped):
- **The map reports 85 monuments, and their names repeat.** There are two Harbors (`harbor_1`,
`harbor_2`), two Fishing Villages, two Abandoned Supermarkets, seven Train Tunnels, five Water
Wells and about thirty Substations. Each `MonumentInfo` carries a prefab path, a display phrase, a
tier, a type and bounds. A display name therefore cannot be an option value, which is why D93
exists. The prefab short name is unique per kind, and the instance number separates repeats.
- **The allowlist candidates exist, taken from `GameManifest.Current.entities` rather than from
memory**: 51 crate prefabs, 25 `ScientistNPC_*`, `NPC_Bandit_Guard`, the tunnel and underwater
dwellers, `codelockedhackablecrate.prefab` and `supply_drop.prefab`. Each one the plan names below
was spawned at the Power Plant and reported its type: crates `LootContainer`, the hackable crate
`HackableLockedCrate`, the supply drop `SupplyDrop`, the scientists `ScientistNPC`.
- **A restart is not proof that a placed object is gone, and that is the opposite of UO's town
crier.** Everything was spawned, then `server.save`, then a restart. The crates, the hackable
crate, the supply drop and the loot barrel were all **still there with the same net id**:
`enableSaving` is true on each. All three scientists were **gone**: `enableSaving` is false on
NPCs. So `reconcileByBootId`'s trick is wrong for a crate and right for an NPC. **Reconcile asks
the plugin by net id**, and the plugin answers what the world holds. Only a **wipe** proves that
everything is gone.
- **ZoneManager keeps temporary zones in memory, and its `OnPluginUnloaded` erases every zone the
unloading plugin owns** (`ZoneManager.cs` 3.1.14, line 92). An `oxide.reload RunicGateway`, which
every framework update and every hand reload causes, would silently delete every event zone in the
middle of a run. A reload of ZoneManager itself loses them too, because the whole dictionary goes.
That is D92.
- **`EraseTemporaryZone(owner, id)` erases an unowned zone and returns `true`** (§12.4, still so in
3.1.14). The bridge's own registry decides what it may erase, and never ZoneManager's answer.
**From the bridge** (`Rust-Plugins`, `edge`):
- **There is no at-most-once store.** UO's shard answers a repeated key with the original reply,
and this plugin has nothing like it. Without one, a `prefab.place` retried after a lost answer
places everything twice. **The ownership registry is therefore keyed by core's idempotency key**,
and a repeat of a key answers with the net ids the first call made. The same record answers a
`revert` that arrives with no resources and only the key: *everything placed under that key*.
- **The mirror's owner bug is live** (`RunicGateway.cs` 3144 and 3179, `this` in both).
**From core**: an action is handed `{ runId, stepId, idempotencyKey, scope, params, actor, verify }`
and never the run's participants, which is why D81 needs a tally at all. The timeouts are the
sidecar reply at 10 s and this module's client at 12 s, so every 13a verb declares
`budgetMs: 15000`.
### 28.2 The 13a catalogue
**Budgets** (D89): `rust.prefabs` (crates placed, count), `rust.npcs` (NPCs placed, count) and
`rust.zone.minutes` (zone time held, minutes).
**Option sources**:
| Source | Values | Filled from |
|---|---|---|
| `rust.options.monuments` | `<serverId>/<kind>` or `<serverId>/<kind>#<n>` | each configured server's live monument list, grouped by server. The label is the display phrase plus a grid square, so two Harbors can be told apart |
| `rust.options.prefabs` | an allowlist key (`crate.elite`, `npc.scientist.heavy`, …) | the plugin's allowlist, grouped **Crates** / **NPCs** |
**Actions** (all `risk: 'change'`, `reversible: 'ledger'`, `budgetMs: 15000`):
| Action | Params | Ledger | `cost()` |
|---|---|---|---|
| `rust.zone.open` | `server`, a location, `radius`, `name`, optional `minutes` | one resource: the zone | `{ 'rust.zone.minutes': minutes }` when given |
| `rust.prefab.place` | `server`, a location, `prefab`, `count`, optional `spread` | one resource **per net id** | `{ 'rust.prefabs': count }` or `{ 'rust.npcs': count }` by the prefab's kind |
A **location** is `monument` (from the source) with an optional `offsetX`/`offsetZ` in metres, **or**
`x`/`z` (and an optional `y`, otherwise the terrain height). A step that gives both kinds, or
neither, is refused `retry: false`.
- **A step names its server as a param, the way a lease names it in its target (D73).** A scheduled
run's scope is always `''` (§27.1), so the scope cannot carry it.
- **The allowlist as planned**, trimmed further if the walk cannot see any of it working:
- Crates: `crate.basic`, `crate.normal` (military), `crate.normal2`, `crate.elite`, `crate.tools`,
`crate.hackable`, `supply.drop`, `barrel.loot`.
- NPCs: `npc.scientist` (roam), `npc.scientist.heavy`, `npc.scientist.tethered`,
`npc.bandit.guard`.
- **One resource per net id**, as UO files one per serial. A group of which players looted half
then reconciles per crate, and not all or nothing.
### 28.3 What 13a builds
**Rust-Plugins**: protocol 9, and `overlay.toml`'s protocol advanced to match.
- **The owner fix (D85), first and as its own commit.** A `null` owner, and a read-back after every
grant so the report counts only what the store holds.
- **`world.zone`, `world.place`, `world.revert`, `world.owned` and `world.monuments`.**
- **Every write is gated by `EventsEnabled`** (D94) and bounded by config (D95). The refusal names
the switch or the bound.
- **A monument resolves when the step runs** (D93), from `TerrainMeta.Path.Monuments` in a stable
order (prefab path, then position). The ground height comes from `TerrainMeta.HeightMap`.
- **Raw coordinates are refused off the map** (`World.Size`).
- **The ownership registry** is a file under the framework's data directory, beside the lease
holds. Each entry records the run id, the idempotency key, the kind (zone, crate, NPC), the net id
or zone id, the prefab key, where it was put, the `bootId` and the `wipeId`.
- **A repeated key answers with the original ids** and places nothing (§28.1).
- **`world.revert`** kills what the registry says this run owns: every net id or zone id asked
for, or, given only a key, everything under it. An id that is already gone is a success. **An
id the registry does not hold is `refused`**, never erased, whatever ZoneManager would allow
(§12.4).
- **`world.owned`** answers what the world still holds, checked live:
`BaseNetworkable.serverEntities.Find(net id)` for an entity, the registry and `CheckZoneID` for
a zone. Any entry whose thing is gone is pruned as it walks, as UO's does.
- **Load**: on the same boot, nothing changes. After a restart, NPC entries are gone, found by
looking (the game does not save them, §28.1), and crates are still there. After a new wipe, the
whole registry is dropped and everything reads gone.
- **Zones (D92)**: each zone is created with the bridge as `owner` and an id of its own naming
(`rg-<run>-<n>`). It is re-created from the registry on the bridge's load, on
`OnPluginLoaded(ZoneManager)` and after a restart within the same wipe. ZoneManager being absent
is refused with the reason, as every base-plugin dependency is.
- **Carbon**: nothing conditional is expected. `GameManager`, `BaseNetworkable` and `TerrainMeta`
belong to the game, and ZoneManager is the same `.cs` file on both frameworks. The walk proves it.
**Rust-Link**: protocol 9. `POST /world/zone`, `POST /world/place`, `POST /world/revert`,
`GET /world/owned?runId=` and `GET /world/monuments`, stamped and forwarded as the lease routes are.
The sidecar knows nothing about prefabs, bounds or monuments.
**Module-Rust**:
- `registerEventBudgets` (the three above), `registerEventActions` (the two above) and two more
`registerEventOptionSources`.
- `perform` validates everything a dry run can check without the game: the server is configured,
exactly one kind of location, the bounds (mirrored), and the prefab is in the allowlist. It
answers `verify` there, then sends the key through.
- `revert` sends **no key of its own** (MODULE_API §2.4's `module-uo` lesson: the undo must not
carry the key the do went out under), and treats `refused` as `failed`. `reconcile` asks
`world.owned` and answers `inForce`. Anything unanswerable leaves the group alone.
- **The watch**: the server refresh already writes each server's `bootId` and `wipeId`. A change in
either calls `ctx.events.reconcile()`, and the log line says which. The first sighting after the
module boots is not a change, because core's own boot reconcile covers it.
- The mirror's report (`permSync`) gains the plugin's read-back count, so the admin page shows
*"not landed"* where it used to show *applied*.
- `ci/bundle.json` gains the new file, and the frozen manifest is regenerated if it moves. No new
route, and no swagger change is expected.
**docs**: this section as built, the phase rows, §9's catalogue corrected, and
`rust-link/PROTOCOL.md` §15 for protocol 9.
### 28.4 13a is done when
The row's second half, with D86 added: **a wipe reconciles the ledger instead of stranding it, and
each world verb's teardown is observed in the game.** Walked on the Oxide rig against real core, with
event runs authored in the admin UI:
1. **Each verb, observed.** A zone opened at a monument with an offset: `GetZoneIDs` lists it, and
`IsPlayerInZone` is true for a player moved into it. Crates and NPCs placed at a monument and at
raw coordinates are found by net id where they were put. At teardown every one is gone and every
row reads `reverted`.
2. **The lost answer.** A dispatch whose reply is dropped, then retried with the same key, places
nothing new. A `revert` with no resources and only that key removes what was placed.
3. **Reload and restart.** An `oxide.reload` of the bridge mid-run, and one of ZoneManager: the zone
comes back. A server restart mid-run: the watch calls reconcile, crates stay `confirmed`, NPCs go
`orphaned`, and the zone comes back.
4. **The wipe.** A wipe mid-run (a new save on the rig): the watch calls reconcile, every row goes
`orphaned`, teardown does nothing harmful, and nothing is placed on the new map.
5. **The switch and the bounds.** With `EventsEnabled` off, a place is refused with the switch
named, and reverting an existing run still works. 26 crates is refused on the authoring form and
by the plugin.
6. **The owner fix.** A site grant of a permission another plugin registered lands in Oxide's
store, and the report counts it. Before the fix it read *applied* and held nothing.
7. **Carbon.** At least one of each verb, with reload and restart, on the Carbon rig. If that cannot
be done, this section says so.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust
[w205]: https://gitea.whitlocktech.com/RunicGateway/website/issues/205
[w206]: https://gitea.whitlocktech.com/RunicGateway/website/issues/206
[w207]: https://gitea.whitlocktech.com/RunicGateway/website/issues/207
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit