Files
docs/modules/rust/PLAN.md
wtclaude 069e715b1b docs(modules): the Pterodactyl rig as built, R22, and a correction
Section 14 records R21's first rig, made with the application key and driven
with the client key. Both work; neither can do the other's job, and there is
no egg-write endpoint at all, so a published egg is a JSON file a human
imports.

The tier-2 loop was run rather than described: phase 1's real RunicGateway.cs
pushed from the working tree, oxide.reload through the client API, the edit
observed in Oxide's own log, source restored. About ten seconds end to end.
Three things worked that were not certain to - files/write creates missing
parents, a plugin placed before Oxide exists survives Oxide arriving, and the
plugin compiles and loads on Linux, which no previous phase had established.

Two findings that change decisions. The image installs the framework on EVERY
boot, Carbon from the rolling production_build tag and Oxide from
releases/latest, so a restart is a framework upgrade and neither is pinnable
through the egg. And R20's "wrapper launches the sidecar then RustDedicated"
does not survive Carbon: the entrypoint prepends LD_PRELOAD to the whole
startup string, so the preload would land on the sidecar and the server would
start cleanly, report nothing, and be silently unmodded.

R22 (org lead): the sidecar's configuration moves into the egg's variables.
Nearly free - rust-link already reads all five keys from the environment with
env-over-file-over-defaults precedence - but the game bind must not be
operator-editable, the web bind must derive from an allocation, and the db
path must not be able to agree with REMOVE_FILES.

Also corrects an earlier claim in this branch. The client key listing zero
servers and includes returning empty were both CORRECT; the servers were
being deleted between reads. A differential diagnosis across two API calls
assumes the state did not move between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 21:30:06 -05:00

130 KiB
Raw Blame History

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. R19R22 (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 for the framework reference and §14 for the rig as built.

The dry run 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 is the contract, MODULE_SYSTEM.md the system, EVENTS.md the event design of record, and the Integration 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 Module-uo Routes, schema fragment, prebuilt client chunk, nav
Sidecar RunicGateway/Rust-Link link Owns the game connection and the durable copy
Oxide bridge plugin RunicGateway/Rust-Plugins servuo-plugins C# inside the game, dials out, never blocks
(event capability) Declarations inside Module-Rust (kit ch. 5)

All three were created empty on 2026-09-15. The module is built from integration-kit/template/, 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

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 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 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 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-agnosticsrc/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 filesinstall.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.mdOnClanCreated, 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 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 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 mutationCreateClan, 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:

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

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 4.4.9 2026-06-04 https://umod.org/plugins/Kits.cs
Clans 0.2.10 2026-05-06 https://umod.org/plugins/Clans.cs
PopupNotifications 0.2.1 2026-08-09 https://umod.org/plugins/PopupNotifications.cs
ZoneManager (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 curls 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 itOnClanUpdate(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.

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).

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 hostproceduralmap.<size>.<seed>.<save>.map beside the world save, regenerated only on a wipe. That is exactly 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 meaningsite.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 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 (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 §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.

// 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 (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 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 §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.

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.

04 produce a working read-only multi-server Rust site that an operator can actually install. 67b are the permissions and remote-administration product. 9 is Teams. 10 is the notifications set, whose catalogue is §10. 1213 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. release.yml, the install manifest, the sha256, the host allowlist — and a real install into a running core from a manifest URL Module-Rust + docs An operator installs the empty module from Admin -> Modules and it reaches started
3 The read path, on both frameworks. First hook wave from 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 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. Server list as the landing page, /rust/servers/:id beneath it, killfeed, leaderboard; nav rows; the UI kit (PublicLayout shell, PageHeader props); capabilities; the site.footer.status slot (R13) Module-Rust The site renders the last thing each server said while every server is off
5 Android leg A (R10). Capability-driven shell from GET /api/v1/public/modules, plus the phase-4 screens Android-app The app renders a Rust site it has never seen, and a UO site unchanged
6 Identity (R1), and the admin.users.detail slot (R13) 3 + docs A player links an account in-game; an operator sees the Steam id inside core's own user page
7 Site-owned permissions (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The PermissionExists pre-check stays the mechanism on both frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire 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). Recursive walk of Interface.Oxide.ConfigDirectory — never DataDirectory, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on OnPluginLoaded, automatic rollback over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail all 3 + docs An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested <Mod>/x.json is found and reloads the right plugin
8 Android leg B (R10). Identity and permission surfaces Android-app A player links from the app
9 Teams from first-party clans (R5). Membership event-driven, leadership read off LocalClan at snapshot; declareModuleSlot × 3 for core's team.notify / team.activity / team.forum Module-Rust + 2 The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly
10 Notifications and engagement (R7). Streams, triggers with ceiling and subjectKey, audiences, engagement seeds, announce leg, post hook — the catalogue is §10, including the in-game-popup question Module-Rust + docs The offline raid alert reaches the player whose base it was, and nobody else
11 Android leg C (R10). Inbox and notification preferences for Rust triggers Android-app A Rust notification arrives on a phone and can be switched off there
12 Events: budgets, option sources and the leases (§9). kit ch. 5's own ordering — leases before actions — and every key verified live before it is advertised Module-Rust + 2 A leased value is observed changing in the running game and restored, per key; rust.group.membership expires without core asking
13 Events: the actions (§9, R3, R16). rust.kit.entitle first, then rust.prefab.place and rust.announce; reversible: 'ledger'; the kit option source flags kits with no permission gate, plus reconcile() and the boot-id watch calling ctx.events.reconcile() (§11.1) 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; a wipe reconciles the ledger instead of stranding 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 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 §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 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 R7R14 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 1213 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 §H is a Rust/Oxide compatibility section that already sketched this, and it should have been read before §5 was written. What follows takes its ids and its reasoning as the starting point rather than inventing a parallel set.

§H's thesis, and it is the one to design around: "The lease is the primitive that travels, not the spawn. Double gather rate for the weekend is the canonical Rust community event, and it is exactly lease-with-expiry. Spawning creatures at a landmark is UO-shaped; holding a value for four hours is every game." It also rates Rust the easier case than UO, because Oxide's convars are live by default where ServUO's are mostly cached at boot.

Budgets — what core counts and bounds

Dimension Counts
rust.prefabs objects placed into the world by a run
rust.zone.minutes zone 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 R17CreateOrUpdateTemporaryZone 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

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 sizeself, 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 §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 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 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)", 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:681registries.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 internalsctx.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 VARCHARno 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.

registryregisterRoutes, registerNav, registerExtension, registerFeatureProvider, declareModuleSlot (1.6.0), plus the read side, routesFor and featureProviders.

uiPublicLayout, 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, Rust-Plugins, Module-Rust, plus rust-link/PROTOCOL.md and 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
Server rust-oxide, id 17, identifier e6758c06
Egg 18 Rust Autowipe, ghcr.io/pterodactyl/games:rust
FRAMEWORK oxide
Limits 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it
Allocations 21000 game (default), 21001 query, 21002 RCON, 21003 Rust+, 21004 held for the sidecar's [web].bind
World procedural, size 3000, seed 1234
SFTP 192.168.0.12:2022

The RCON password is a generated 24-byte token 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:

MODIFIED_STARTUP="LD_PRELOAD=$(pwd)/libdoorstop.so ${MODIFIED_STARTUP}"

So a startup beginning with our sidecar becomes:

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.