docs(modules): module-rust phase 0 as built — the rig is current, and four plan claims were wrong #251

Merged
whitlocktech merged 3 commits from docs/rust-phase-0 into main 2026-09-15 21:43:21 +00:00
Member

What & why

Phase 0 of docs/modules/rust/PLAN.md §5 — the rig. Follow-up to #249 and #250.

D:\rust is now on build 25230300 with Oxide 2.0.7716 and all four base plugins loaded at exactly the versions R6/R17 name (Kits 4.4.9, Clans 0.2.10, Popup Notifications 0.2.1, Zone Manager 3.1.14). They compiled first time on the new build.

The interesting half is that the rig disproved four things the plan asserted. Adds §12 and wires the corrections into §4, R2, R5 and R17.

Updated after org-lead review (98bed8f). The first revision of this PR presented "updating Rust un-installs Oxide" as a finding operators would be caught by. It isn't — pairing a server update with an Oxide re-install is the routine every Rust host already follows, and writing it up that way talks down to the audience. §4 and §12.1 now say so plainly, and keep only the narrow part that is actually ours (see below).

1. The rig's own script never updated anything (§4)

The plan recorded start.bat as "updates C:\rustserver, runs D:\rust" and "fixed" it by changing the path. The path was never the problem. steamcmd requires +force_install_dir before +login, and the script had it after:

steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit
→  Please use force_install_dir before logon!
→  Error! App '258550' state is 0x486 after update job.

So the flag was discarded and every update run in the rig's history errored out without updating any directory. That — not a misdirected install — is why D:\rust fell a wipe behind, and it is corroborated by C:\rustserver sitting at the same stale buildid rather than a newer one.

The Oxide re-install is routine and is recorded as such. One narrow consequence still belongs to phase 18: because app_update leaves Oxide.Core.dll and the rest in place, a doctor check written the obvious way — "does oxide/ exist", "are Oxide's assemblies present" — passes on a server that is mid-routine. Compare Assembly-CSharp.dll against the Oxide build so doctor reports the real state rather than a directory listing.

2. Four rules the R2 permission push must obey (§12.2)

Verified live, granting and revoking through both the console command and the API. The one with teeth:

permission.GrantUserPermission silently no-ops when the permission is not registered. void, no throw, no log — the console oxide.grant at least answers Permission 'x' doesn't exist, but the API path R2 actually uses says nothing. Since R2's recovery story is "the site re-pushes the whole set on connect", a re-push into a server missing one plugin is a silent partial, and the site believes it pushed privilege the game never received.

Also: RegisterPermission warns about a foreign prefix but registers anyway (so the site can make a grant stick for an absent plugin, at the cost of a warning); and a player who has never connected is in no group yet can hold direct grants — so grants and groups have different reach for offline players, which phase 7 currently treats as two spellings of the same thing. R16's offline entitlement is safe; a group-shaped one would not be.

3. R5's claim about the Clans plugin was a grep artefact (§12.3)

The plan says the plugin raises "exactly three hooks — OnClanCreate, OnClanChat, OnAllianceChat — none of which is a membership transition" and concludes it cannot answer core's provider questions at all.

It raises nine. The six that were missed 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);

OnClanMemberJoined, OnClanMemberGone and OnClanDisbanded carry the full member list, and OnClanUpdate(tag) fires on promote and demote — the exact transitions first-party lacks and that R5 calls "the one gap to design around".

The decision does not change, but its stated reason had to. First-party stays the provider source because every server has it and the plugin is an optional install; feeding a provider from something optional makes Teams conditional on a mod. Phase 17 now gains event-driven leadership as a sharpening of phase 9's snapshot rather than a replacement.

Two smaller things from the same read: Clans raises each hook name twice per transition (Rust-typed and Universal-typed, both arity 3 — a loosely typed subscriber double-counts every join), and it calls API_RegisterThirdPartyTitle itself, so R15's BetterChat work will be the second title provider on any server running both.

4. ZoneManager's owner scoping is narrower than R17 assumed — a safety correction (§12.4)

R17 leans on EraseTemporaryZone(owner, zoneId) being "scoped to the owner" as "most of ch. 4's persisted ownership registry". The scoping is one-directional:

// 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, i.e. 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. Phase 12's own id map is therefore load-bearing, not convenient: it must refuse to erase any id it did not record creating, because ZoneManager will not refuse on our behalf.

Also recorded there: GetPlayersInZone returns an empty list for an unknown zone as well as an empty one (the absence-of-an-answer trap again), and NPCs never enter a zone's player list.

5. One acceptance criterion stays open, and not by choice of method (§12.5)

oxide.grant demonstrably gates something, and a test zone reports who is standing in it

The grant half is done — proven in both directions for an online-known and a never-seen player, through the same UserHasPermission call every third-party plugin makes.

The occupancy half cannot be closed headlessly. A plugin's permission check reads return !player || permission.UserHasPermission(...), and a command from RCON has no BasePlayer — so the server console is unconditionally allowed and no console session can ever observe a gate closing. Zone occupancy has the same shape from the other end: baseEntity is BasePlayer { IsNpc: false } excludes NPCs by construction, so only a real connected player puts a name in that list.

Everything up to the body was proven: a zone created through the API, listed by both the probe and ZoneManager's own zone_list, read for occupancy (a real empty list, so the call resolved), and erased. What remains is one person, connected, standing inside it.

This is flagged as a standing property of the rig, not a phase-0 leftover — phases 7 and 12 have acceptance criteria of the same shape.

How it was tested

Everything in §12 was observed on the live rig, not read off a source:

  • Update, Oxide overlay and first boot on D:\rust; all four plugins compiled and loaded (oxide.plugins, oxide.version).
  • The permission findings via a dependency-free WebSocket RCON driver and RGProbe.cs, a throwaway Oxide plugin exposing Oxide's permission API and ZoneManager's by-name API as console commands. Both kept in the phase-0 scratchpad rather than committed; the probe is a reasonable starting point for phase 1's skeleton.
  • Zone create / list / occupancy / erase through Call() on the live server, including the unowned-zone erase.
  • The Clans and Kits hook claims read out of the 0.2.10 and 4.4.9 sources as installed; the seven first-party hooks re-checked against agent/hooks.tsv.
  • Test grants were revoked afterwards; oxide.show user confirms both ids clean.

One thing worth repeating from §12.0: Oxide tags its own Puts() output with the identifier of the command being run, so a first-match-wins RCON client reads a plugin's log line as the reply and discards the real one. It produced two wrong readings before it was spotted.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

## What & why Phase 0 of [`docs/modules/rust/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust/PLAN.md) §5 — the rig. Follow-up to #249 and #250. `D:\rust` is now on build **25230300** with Oxide **2.0.7716** and all four base plugins loaded at exactly the versions R6/R17 name (Kits 4.4.9, Clans 0.2.10, Popup Notifications 0.2.1, Zone Manager 3.1.14). They compiled first time on the new build. The interesting half is that the rig disproved four things the plan asserted. Adds §12 and wires the corrections into §4, R2, R5 and R17. > **Updated after org-lead review (`98bed8f`).** The first revision of this PR presented "updating Rust un-installs Oxide" as a finding operators would be caught by. It isn't — pairing a server update with an Oxide re-install is the routine every Rust host already follows, and writing it up that way talks down to the audience. §4 and §12.1 now say so plainly, and keep only the narrow part that is actually ours (see below). ### 1. The rig's own script never updated anything (§4) The plan recorded `start.bat` as *"updates `C:\rustserver`, runs `D:\rust`"* and "fixed" it by changing the path. **The path was never the problem.** steamcmd requires `+force_install_dir` before `+login`, and the script had it after: ``` steamcmd.exe +login anonymous +force_install_dir d:\rust\ +app_update 258550 +quit → Please use force_install_dir before logon! → Error! App '258550' state is 0x486 after update job. ``` So the flag was discarded and every update run in the rig's history errored out without updating **any** directory. That — not a misdirected install — is why `D:\rust` fell a wipe behind, and it is corroborated by `C:\rustserver` sitting at the *same* stale buildid rather than a newer one. The Oxide re-install is routine and is recorded as such. One narrow consequence still belongs to **phase 18**: because `app_update` leaves `Oxide.Core.dll` and the rest in place, a `doctor` check written the obvious way — "does `oxide/` exist", "are Oxide's assemblies present" — **passes on a server that is mid-routine**. Compare `Assembly-CSharp.dll` against the Oxide build so `doctor` reports the real state rather than a directory listing. ### 2. Four rules the R2 permission push must obey (§12.2) Verified live, granting and revoking through both the console command and the API. The one with teeth: **`permission.GrantUserPermission` silently no-ops when the permission is not registered.** `void`, no throw, no log — the console `oxide.grant` at least answers `Permission 'x' doesn't exist`, but the API path R2 actually uses says nothing. Since R2's recovery story is *"the site re-pushes the whole set on connect"*, a re-push into a server missing one plugin is a **silent partial**, and the site believes it pushed privilege the game never received. Also: `RegisterPermission` warns about a foreign prefix but registers anyway (so the site *can* make a grant stick for an absent plugin, at the cost of a warning); and a player who has never connected is in **no group** yet can hold direct grants — so grants and groups have different reach for offline players, which phase 7 currently treats as two spellings of the same thing. R16's offline entitlement is safe; a group-shaped one would not be. ### 3. R5's claim about the Clans plugin was a grep artefact (§12.3) The plan says the plugin raises *"exactly **three** hooks — `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"* and concludes it cannot answer core's provider questions at all. It raises **nine**. The six that were missed are invisible to a search for `CallHook("OnClan…` because the name is a `const` at the call site: ```csharp const string HOOK_NAME = "OnClanMemberJoined"; Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList); ``` `OnClanMemberJoined`, `OnClanMemberGone` and `OnClanDisbanded` **carry the full member list**, and `OnClanUpdate(tag)` fires on promote and demote — the exact transitions first-party lacks and that R5 calls "the one gap to design around". **The decision does not change, but its stated reason had to.** First-party stays the provider source because every server has it and the plugin is an optional install; feeding a provider from something optional makes Teams conditional on a mod. Phase 17 now gains event-driven leadership as a *sharpening* of phase 9's snapshot rather than a replacement. Two smaller things from the same read: `Clans` raises each hook name **twice** per transition (Rust-typed and Universal-typed, both arity 3 — a loosely typed subscriber double-counts every join), and it calls `API_RegisterThirdPartyTitle` itself, so R15's BetterChat work will be the *second* title provider on any server running both. ### 4. ZoneManager's owner scoping is narrower than R17 assumed — a safety correction (§12.4) R17 leans on `EraseTemporaryZone(owner, zoneId)` being "scoped to the owner" as *"most of ch. 4's persisted ownership registry"*. The scoping is one-directional: ```csharp // Only compare zone owner if the owner param is provided so users can remove temporary zones // without needing to unload the plugin that created them if (owner && zoneOwner && owner != zoneOwner) return false; ``` `zoneOwner` is null for every permanent zone, i.e. 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. Phase 12's own id map is therefore load-bearing, not convenient: it must refuse to erase any id it did not record creating, because ZoneManager will not refuse on our behalf. Also recorded there: `GetPlayersInZone` returns an empty list for an **unknown** zone as well as an empty one (the absence-of-an-answer trap again), and NPCs never enter a zone's player list. ### 5. One acceptance criterion stays open, and not by choice of method (§12.5) > `oxide.grant` demonstrably gates something, **and a test zone reports who is standing in it** The grant half is **done** — proven in both directions for an online-known and a never-seen player, through the same `UserHasPermission` call every third-party plugin makes. The occupancy half cannot be closed headlessly. A plugin's permission check reads `return !player || permission.UserHasPermission(...)`, and a command from RCON has no `BasePlayer` — so **the server console is unconditionally allowed** and no console session can ever observe a gate closing. Zone occupancy has the same shape from the other end: `baseEntity is BasePlayer { IsNpc: false }` excludes NPCs by construction, so only a real connected player puts a name in that list. Everything up to the body was proven: a zone created through the API, listed by both the probe and ZoneManager's own `zone_list`, read for occupancy (a real empty list, so the call resolved), and erased. What remains is one person, connected, standing inside it. This is flagged as a **standing property of the rig**, not a phase-0 leftover — phases 7 and 12 have acceptance criteria of the same shape. ## How it was tested Everything in §12 was observed on the live rig, not read off a source: - Update, Oxide overlay and first boot on `D:\rust`; all four plugins compiled and loaded (`oxide.plugins`, `oxide.version`). - The permission findings via a dependency-free WebSocket RCON driver and `RGProbe.cs`, a throwaway Oxide plugin exposing Oxide's permission API and ZoneManager's by-name API as console commands. Both kept in the phase-0 scratchpad rather than committed; the probe is a reasonable starting point for phase 1's skeleton. - Zone create / list / occupancy / erase through `Call()` on the live server, including the unowned-zone erase. - The Clans and Kits hook claims read out of the 0.2.10 and 4.4.9 sources as installed; the seven first-party hooks re-checked against `agent/hooks.tsv`. - Test grants were revoked afterwards; `oxide.show user` confirms both ids clean. One thing worth repeating from §12.0: Oxide tags its own `Puts()` output with the **identifier of the command being run**, so a first-match-wins RCON client reads a plugin's log line as the reply and discards the real one. It produced two wrong readings before it was spotted. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-15 20:09:07 +00:00
Brings D:\rust to build 25230300 with Oxide 2.0.7716 and the four base
plugins loaded, and records what the rig disproved.

- §4: start.bat never updated anything. +force_install_dir must precede
  +login; the 2026-09-15 fix corrected the path and left the order, so
  every update run in the rig's history errored without updating. Also:
  app_update reverts Oxide's patched Assembly-CSharp.dll while leaving
  Oxide's own assemblies, producing a server that looks Oxided and loads
  no plugins. Phase 18's doctor should check for this.
- R2: permission.GrantUserPermission silently no-ops for an unregistered
  permission — void, no throw, no log. Since R2 recovers by re-pushing
  the full set on connect, a re-push into a server missing one plugin is
  a silent partial. Plus: grants and groups have different reach for a
  player who has never connected.
- R5: the Clans plugin raises nine hooks, not three, and three carry full
  member lists. The "exactly three, none a membership transition" claim
  was a grep artefact — the hook name is a const at the call site. The
  decision stands on a different reason; phase 17 gains event-driven
  leadership as a sharpening rather than a replacement.
- R17: EraseTemporaryZone's owner scoping refuses only a *different*
  owner. An unowned zone — every zone an operator made by hand — is
  erased by anyone and returns true. Phase 12 must gate on its own id map.
- §12.5: the zone-occupancy criterion stays open. A console command has no
  BasePlayer, so every plugin's permission check short-circuits to allowed
  and no console session can observe a gate closing; NPCs are excluded
  from zone player lists by construction. This is a standing property of
  the rig, relevant at phases 7 and 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-15 21:11:28 +00:00
Org lead correction: every Rust host already updates the server and
re-installs Oxide together. Presenting that as something an operator
would be caught by talks down to the audience.

Keeps the one narrow consequence that is actually ours: app_update
leaves Oxide.Core.dll and the rest in place, so a phase-18 doctor check
that tests for oxide/ or for Oxide's assemblies passes on a server that
is mid-routine. Compare Assembly-CSharp.dll against the Oxide build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-15 21:40:34 +00:00
The org lead connected, so the occupancy criterion is met: a zone created
on the player's own position reported OnEnterZone, occupancy=1 by name,
and OnExitZone. R17's "presence transitions as events" is verified, which
is what the participation ledger and advance conditions rest on.

The exit was produced by relocating the zone rather than walking the
player out — CreateOrUpdateZone on an existing id moves the trigger
volume and fires OnExitZone. Useful for testing presence unattended.

Two ceilings recorded, both constraining later phases:

- No console session can observe a gate (the !player || idiom).
- An admin account cannot see a refusal from most plugins either, and the
  bypass is NOT uniform. PopupNotifications (player.IsAdmin) and
  ZoneManager (authLevel > 0) hard-bypass; Kits' RequiredPermission gate
  is config-gated with a shipped default of false, and Kits' own IsAdmin
  is a permission rather than auth level. So phase 13 is demonstrable on
  this rig as it stands and phase 7 is not — phase 7 needs a second,
  non-admin Steam account.

Adds §12.6: R18's two trees as they actually look now the configs exist.
Its data/ inventory was exactly right, but config/ is FLAT on a fresh
base set — it is data/ that nests, i.e. the nesting R18 cites to justify
the recursive walk is currently only visible in the tree it must never
walk. Also: oxide/data/ holds Oxide's own permission store, so straying
one directory over would edit R2's mirror underneath itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit 80c7a9dcd1 into main 2026-09-15 21:43:21 +00:00
whitlocktech deleted branch docs/rust-phase-0 2026-09-15 21:43:28 +00:00
Sign in to join this conversation.
No description provided.