From 947e1c1c6796ee9236ae767aaca0c19d5e1cdf52 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 15:07:59 -0500 Subject: [PATCH 1/3] =?UTF-8?q?docs(modules):=20phase=200=20as=20built=20?= =?UTF-8?q?=E2=80=94=20the=20rig=20is=20current,=20and=20four=20plan=20cla?= =?UTF-8?q?ims=20were=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 266 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 241 insertions(+), 25 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index f3937c2..73537c2 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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)`** | 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, "")` **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/.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`) and once Universal-typed (`string, string, List`), 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 From 98bed8ff3d3676d2ce38bade0a4b09f2e5c8ab48 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 16:04:23 -0500 Subject: [PATCH 2/3] docs(modules): re-install Oxide after an update is routine, not a finding 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 73537c2..8948535 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -718,11 +718,12 @@ Three traps recorded here because each cost time before it was understood: 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. +- **Re-extract Oxide after every `app_update`.** Updating the server and re-installing Oxide together + is the standard operator routine, not a discovery — Oxide ships a *patched* `Assembly-CSharp.dll` + and a Steam update restores Facepunch's. Recorded here only for the mechanical detail: 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. Check the size rather than the directory — 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,953,280, so copying it over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it. @@ -1202,18 +1203,19 @@ skeleton can start from it. > 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 +### 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. 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. +"fix" changed the path and left the order, so it fixed nothing. -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. +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 From b12c6dfde458ed831780e82aa71dd6f8fd7d6b82 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 15 Sep 2026 16:40:08 -0500 Subject: [PATCH 3/3] =?UTF-8?q?docs(modules):=20phase=200=20fully=20closed?= =?UTF-8?q?=20=E2=80=94=20both=20zone=20transitions=20observed=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- modules/rust/PLAN.md | 82 +++++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 8948535..ad91073 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -748,7 +748,7 @@ 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. 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 | +| 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.** 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 | @@ -1173,8 +1173,9 @@ The app needs no failure handling for this case because core does not expose the ## 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. +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 @@ -1284,34 +1285,67 @@ Three more things the source and the live rig agreed on: 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 +### 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 -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. +**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: -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); -} +``` +[probe] ENTER zone=rgtest player=76561198038695917 (whitlocktech) +[probe] zone=rgtest occupancy=1 [76561198038695917:whitlocktech] +[probe] EXIT zone=rgtest player=76561198038695917 (whitlocktech) ``` -`!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. +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. -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. +So **R17's "presence transitions as events" is verified**, which is the claim the participation +ledger and the advance conditions both rest on. -**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. +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//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. [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit