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

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
This commit is contained in:
2026-09-15 15:07:59 -05:00
parent cbf322b4d6
commit 947e1c1c67

View File

@@ -93,6 +93,16 @@ Three properties fall out, and they are the reason this shape is worth its cost:
drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it
finds a value a human has moved ([kit][kit] ch. 5).
**Phase 0 exercised all three against the real store and they hold — but it found four rules the
push path has to obey (§12.2).** The one that would have cost the most:
**`permission.GrantUserPermission` silently no-ops when the permission is not registered.** It
returns `void`, throws nothing and logs nothing; the grant simply does not happen. A permission is
registered by the plugin that declares it, so **every grant naming an unloaded, renamed or
uninstalled plugin's permission disappears without a trace** — and since R2's whole recovery story is
"the site re-pushes the full set on connect", a re-push into a server missing one plugin is a silent
partial. The push must check `PermissionExists` (or register the name itself) and report the
difference as drift rather than assuming a write landed.
**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and
4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a
teardown. This is neither: it is *continuously reconciled state where the website is authoritative*,
@@ -171,12 +181,30 @@ direction worth stating plainly: for Teams, the plugin is *worse* than first-par
`Clans` v0.2.10 (k1lly0u, MIT, 2,692 lines) publishes **fifteen `[HookMethod]`s and every one of
them is a mutation** — `CreateClan`, `JoinClan`, `LeaveClan`, `KickPlayer`, `PromotePlayer`,
`DemotePlayer`, `DisbandClan`, and the eight alliance verbs. **There is no read API whatsoever**: no
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`. And it raises exactly **three**
hooks — `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — **none of which is a membership
transition**.
`GetClan`, no `GetClanOf`, no `GetClanMembers`, no `GetAllClans`.
So it cannot answer any of core's three provider questions from its published surface, while
first-party clans answer all three. Feeding the Team provider from first-party clans is therefore
> **Corrected in phase 0 (§12.3).** This paragraph continued *"it raises exactly **three** hooks —
> `OnClanCreate`, `OnClanChat`, `OnAllianceChat` — none of which is a membership transition"*, and
> concluded that the plugin cannot answer core's provider questions at all. **That was a grep
> artefact and it is wrong.** `Clans` 0.2.10 raises **nine** hooks. The missing six are invisible to
> a search for `CallHook("OnClan…` because the name is a `const` at the call site:
>
> ```csharp
> const string HOOK_NAME = "OnClanMemberJoined";
> Interface.CallHook(HOOK_NAME, tag, ulong.Parse(joining), RustMemberList);
> ```
>
> They are `OnClanMemberJoined(tag, joining, members)`, `OnClanMemberGone(tag, leaving, members)`,
> `OnClanDisbanded(tag, members)`, `OnClanAllianceCreated`, `OnClanAllianceDissolved`, and
> `OnClanUpdate(tag)`. The first three **carry the full member list**, so the plugin *can* answer
> "who is in this clan" without any read API, and `OnClanUpdate` fires on promote and demote — the
> exact transitions first-party lacks.
**The decision does not change, but its reason does.** First-party remains the Team provider's source
because it is the system the *game* maintains and every server has it; the plugin is an optional
install that not every shard will run, and feeding a provider from something optional makes Teams
conditional on a mod. It is no longer true that the plugin *cannot* answer the questions — only that
it should not be the one asked. Feeding the Team provider from first-party clans is still
**permanent, not a first step**.
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
@@ -263,6 +291,12 @@ kicked and left — but **no promote or leader-changed event**. Core's provider
transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
fully event-driven — a small correction to that document, recorded here rather than silently.
Phase 0 verified the seven against `agent/hooks.tsv` and the gap is real. It also found that the
**Clans plugin closes it**`OnClanUpdate(tag)` fires on both promote and demote (§12.3). That does
not move the provider off first-party, but it does mean phase 17's adapter can offer *event-driven
leadership* on servers that run the plugin, over a snapshot baseline on servers that do not. Design
phase 9's snapshot so phase 17 can sharpen it rather than replace it.
### R7 — the notifications and engagement set ships in v1
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
@@ -519,7 +553,7 @@ someone goes looking for one that is not there.
| **`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, **scoped to the owner** |
| **`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`,
@@ -543,10 +577,30 @@ 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. That is most of the persisted
ownership registry [kit][kit] ch. 4 demands — we still keep our own map from core's resource
reference to the zone id, but we are not inventing ownership, we are borrowing a concept the plugin
already has. And erasing a zone that is gone is a success, which is what `revert` needs.
first-class notion of a zone belonging to the plugin that made it. And erasing a zone that is gone is
a success, which is what `revert` needs.
> **Narrowed in phase 0 (§12.4), and this one is a safety correction.** The scoping is real but it is
> *one-directional*: it stops us erasing a zone owned by **another plugin**, and does nothing at all
> for a zone owned by **nobody**.
>
> ```csharp
> // Only compare zone owner if the owner param is provided so users can remove temporary zones
> // without needing to unload the plugin that created them
> if (owner && zoneOwner && owner != zoneOwner)
> return false;
> ```
>
> `zoneOwner` is null for every *permanent* zone — which is every zone an operator made by hand. So
> `EraseTemporaryZone(us, "<operator's zone>")` **deletes it and returns `true`**, indistinguishable
> from erasing our own. Observed live: a zone created with `CreateOrUpdateZone` and no owner was
> erased by an `EraseTemporaryZone` call from an unrelated plugin.
>
> So this is **less** of ch. 4's persisted ownership registry than the paragraph above claims. We
> still keep our own map from core's resource reference to the zone id, and that map is now
> **load-bearing rather than convenient**: phase 12 must refuse to erase any zone id it did not
> record creating. ZoneManager will not refuse on our behalf, and the `true` it returns is not
> evidence the zone was ours.
**One trap to design against, and it is chapter 4's rule meeting a chatty hook.** `OnEnterZone` and
`OnExitZone` fire on the game thread and a large zone with a busy server produces a great many of
@@ -635,22 +689,42 @@ online. The persisted pending-grant queue that question was weighing is not need
## 4. The test rig
`D:\rust` on the org lead's workstation. It has been booted, it has a generated world
(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its
Oxide permission store already holds a `default` and an `admin` group with one admin user — which
means R2's mechanism can be exercised on day one.
`D:\rust` on the org lead's workstation. **Brought current in phase 0 (see §12):** build
**25230300**, Oxide **2.0.7716**, a fresh procedural world (seed 1234, size 4000) generated for this
wipe, and all four base plugins loaded. Its Oxide permission store holds a `default` and an `admin`
group with one admin user, so R2's mechanism was exercised on day one and works.
Two traps recorded here because both cost time before they were understood:
A wipe keeps `server/server1/cfg/`. That directory holds `users.cfg`, and `users.cfg` holds the
`ownerid` line — delete the whole identity directory and you silently remove the operator's own
ownership along with the map.
- **`D:\rust\start.bat` updated the wrong directory — fixed 2026-09-15.** It ran
`steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launched
`D:\rust\RustDedicated.exe`. The server that boots had never been updated by its own script, which
is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind.
Now reads `+force_install_dir d:\rust\`; the original is kept at `D:\rust\start.bat.bak`.
`D:\rust\steamapps\appmanifest_258550.acf` was already present at the same buildid, so the first
corrected run is a delta to the current wipe rather than a 5.9 GB re-download.
Three traps recorded here because each cost time before it was understood:
- **`start.bat` never updated anything — the 2026-09-15 diagnosis was wrong, corrected in phase 0.**
The script was read as *"updates `C:\rustserver`, runs `D:\rust`"*, and the fix changed the path to
`d:\rust\`. 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, the update ran against steamcmd's own directory, and the job errored out
every single time. It updated **no** directory, ever — which is the actual reason `D:\rust` fell a
wipe behind, and why `C:\rustserver` sits at the *same* stale buildid rather than a newer one.
Now reads `+force_install_dir d:\rust\ +login anonymous +app_update 258550 +quit`; the pristine
original is kept at `start.bat.bak` and the path-only fix at `start.bat.broken-order-20260915`.
With the ordering right, the run is a delta and takes minutes.
- **`app_update` silently un-Oxides the server.** Oxide ships a *patched* `Assembly-CSharp.dll`, and
a Steam update restores Facepunch's. It does not remove `Oxide.Core.dll` and friends, so the
install still *looks* Oxided — it simply loads no plugins and raises no hook. **Re-extract
`Oxide.Rust.zip` over the tree after every `app_update`, and check the size**: on build 25230300
vanilla is 9,758,544 bytes and Oxide 2.0.7716's is 9,953,280.
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,953,280, so copying it
over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it.
**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the
@@ -673,7 +747,7 @@ Each phase ends with its findings written down, as every workstream here does.
| # | Phase | Repos | Done when |
|---|---|---|---|
| 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 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. One acceptance criterion is **open**: zone occupancy needs a connected player and cannot be closed headlessly (§12.5) | 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.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | 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.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history |
@@ -925,7 +999,7 @@ cleanly, reads back cleanly, and does nothing at all, and neither core nor revie
| `rust.kit.entitle` | `change` | `ledger` | R16 — grants the kit's `RequiredPermission`; revert revokes |
| `rust.prefab.place` | `change` | `ledger` | §H's verb; revert kills the entity, and needs the persisted ownership registry ch. 4 describes |
| `rust.announce` | `notify` | `none` | via PopupNotifications (R6) — global or targeted |
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17**`CreateOrUpdateTemporaryZone` takes a `Plugin owner` and `EraseTemporaryZone` is scoped to it, so the undo is real |
| `rust.zone.open` | `change` | `ledger` | §H's other verb. **Base, not optional, since R17** — `CreateOrUpdateTemporaryZone` takes a `Plugin owner`, so the undo is real. Our own id map decides what may be erased, not ZoneManager's owner check (§12.4) |
**Rewards are not a contract member.** `EVENTS.md` deleted a `registerEventRewards` registry because
it carried four Ultima Online nouns inside a core signature. A reward here is an ordinary action —
@@ -1096,4 +1170,146 @@ So R10's capability probe already has the behaviour the app wants: a Rust module
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 and the base set runs. Five things were learned that the plan had either wrong or
had never asked, and three 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 scripts were 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. Separately, `app_update` reverts
Oxide's patched `Assembly-CSharp.dll` while leaving Oxide's own assemblies in place, which produces a
server that looks Oxided and loads no plugins.
Both matter beyond this workstation: **phase 18's installer will hit the second one.** An
operator who updates their Rust server has silently uninstalled Oxide, and therefore our plugin.
`doctor` should check the `Assembly-CSharp.dll` against the Oxide build, not merely that `oxide/`
exists.
### 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 one criterion phase 0 could not close, and why
> `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it
The first half is **done** — proven for both an online-known and a never-seen player, in both
directions, through the same `UserHasPermission` call every third-party plugin makes.
The second half is **open, and not by choice of method.** A plugin's own permission check reads:
```csharp
private bool HasPermission(ConsoleSystem.Arg arg, string perm)
{
BasePlayer player = arg.Player();
return !player || permission.UserHasPermission(player.UserIDString, perm);
}
```
`!player ||` — a command from RCON has no `BasePlayer`, so **the server console is unconditionally
allowed**. This is the standard Oxide idiom, not a ZoneManager quirk, and it means no console session
can ever observe a gate closing. Zone occupancy has the same shape from the other end: NPCs are
excluded by construction, so nothing but a real connected player puts a name in that list.
Everything up to the body was proven: a zone was 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 a standing property of the rig, not a phase-0 leftover.** Any phase whose acceptance
depends on a permission actually *refusing* somebody, or on presence, needs a client attached. Worth
knowing at phase 7 and phase 12 rather than discovering there.
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit