docs(modules): phase 8 as built, and the rig that finally put the sidecar where the design says it lives

Three documents:

* `modules/rust/PLAN.md` §22 — the phase as built. Three org-lead decisions
  (D39-D41), what a player is told and what they are not, the refusals on a
  phone, and an honest limit the rig found: a rank can be live while every
  permission it carries resolves nowhere.
* `rust-link/INSTALL_RIG.md` — new. The sidecar runs INSIDE the game
  container now, which is the shape R20 says the egg ships and which retires
  the firewall wall phases 6, 7 and 7b each stopped at. A container's
  127.0.0.1 is genuinely private, so a stock plugin config and a stock
  sidecar find each other with nothing configured at all. Three things in
  the launcher are load-bearing and each is written down with the failure it
  prevents.
* `rust-link/PLAYER_WALK.md` — the account walk on a phone, and a correction:
  7b's "it needs a firewall rule on a development machine" is no longer true.

`android/PLAN.md` gains M15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 20:35:46 -05:00
parent 15d64b28fe
commit 11b8965aa7
4 changed files with 346 additions and 3 deletions

View File

@@ -986,7 +986,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
| 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
| 9 | **Teams from first-party clans** (R5). 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 |
@@ -3206,6 +3206,107 @@ down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
- **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so
`requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member.
## 22. Phase 8 as built — the player's own half, on two screens, 2026-09-22
**Walked end to end against a live rig, and the rig is why this phase reads differently from the
three before it.** The sidecar now runs inside the game container, so for the first time since phase
5 the website talked to a real plugin on a real Oxide server rather than to a stand-in — including
the permission push, which landed a group membership in Oxide's own store and reported
`unresolved` for a name nothing had registered.
Two repositories and this document: [`Module-Rust#10`][mr] (the website half, into `edge`) and
[`Android-app#48`][aa] (M15, into `edge`). Core needed no change — the third Rust phase running.
### 22.0 The three decisions this phase needed
| | Decision |
|---|---|
| **D39** | **The app's permission half is player-facing and read-only, and the website grows the route for it.** Phase 7's whole surface is `requireRole('admin')`, and the app has no admin user-detail screen to port it into; porting the authoring page to a phone was considered and rejected (it writes into a running game from a device that is easy to mis-tap, and M14 already excluded the Rust admin surface as configuration the app consumes rather than edits). So the phase adds one self-scoped read instead — and renders it on the **website** as well, so the app never has a surface the website lacks. |
| **D40** | **It mirrors `module-uo`'s player surface, not a shape of its own.** One drawer row under the player group, with the code card at the top of the screen it leads to — exactly `CharactersScreen`. A tab under one server was rejected because a link is fleet-wide, and a section inside core's own Account screen was rejected because the app has no slot mechanism and the module's data would be hard-wired into a core screen. |
| **D41** | **The row hangs on `rust`, matching `module-uo`'s single `shard`.** The module declares a surface word per feature and `identity` is one of them, but D16's rule stands: a capability answers *is the module there*, and core flattens every module's capabilities into one list. Requiring `rust` **and** `identity` was considered — it would refuse the screen on a build predating phase 6 — and rejected as a second meaning for a word that has one. |
### 22.1 What the player is told, and what they are not
`GET /player/rust/permissions` is a different shape from the admin read rather than a filtered one,
and the three differences are the phase:
1. **The scope is resolved on the server.** A client handed `scope: "*"` would have to know what the
fleet is to say anything, and then `inScope` exists twice. Each entry arrives carrying the
servers it reaches, each already marked.
2. **`live` is the pushed ledger, never the authored row.** Phase 7 is careful never to record a
push that silently did nothing, so *waiting* here means waiting — and the alternative is the site
claiming to have given something it has not.
3. **Nothing says why it is waiting.** An offline server, a permission no loaded plugin registered
and a store that has never seen the account are one state on this screen. Telling them apart is
an operator's diagnosis and an inventory of what is installed on a host.
**An entitlement that reaches nobody still lists**, and both surfaces say so: authored against the
website account, it exists before a Steam id does. Hiding it until one turns up is the defect the
admin user page shipped in phase 7 (§20.5) — the same mistake, one tier along, caught before it
shipped this time because the rule was already written down.
**The honest limit, named rather than designed around:** a *rank* can be live while every permission
it carries resolves nowhere. The rig proved it — `vip` was created in Oxide's store and the
membership landed, while `kits.vip` came back `unresolved` because Kits is not installed there. The
player is told the rank reached the game, which is true and is what the site gave them; whether a
server's plugins understand the names inside it is on the admin screen, where the warning already
is.
### 22.2 The refusals, on a phone
The app's convention since M1 is that a refusal is chosen by **status** and rendered from a string
resource — the website's sentence is never displayed, because the app is localized and the website
is not. That convention holds here and it costs something worth writing down: the module
distinguishes *three* 503s (a server it could not reach, a fleet that is all down, a site with no
servers configured at all) by sentence, and the app has one string for the status. It is written to
be true of all three, and it does **not** say "get a new code" — a player told that would go back to
the same unreachable server for another one.
The four that do differ by status keep four different pieces of advice: 400 a spent code, 409 a
Steam account another website account holds, 429 the limiter, 503 a server that could not be
reached.
### 22.3 What the walk proved, and on what
- **The website half** in a browser, against the live rig: the section rendering with zero linked
accounts, a rank marked *has it* and a grant marked *waiting* on the same screen, the note that
explains why, and the refusal path.
- **The app half** on an emulator against the same core: the row absent when signed out and absent
on a UO site, present for a signed-in player; both reads; a refused code rendering **beside the
button**; the marks; and the release.
- **The push itself against a real Oxide store**, which is new. `perm.sync` created the group,
applied the membership, and reported `unresolved: ["kits.vip", "zonemanager.admin"]` — the second
of those a genuine finding about the rig rather than the code: ZoneManager registers
`zonemanager.ignoreflag.*` and friends, and **not** `zonemanager.admin`. A permission name that
looks obvious is still a name some plugin has to have registered.
**The acceptance line needs a person in game.** Everything above used a link row written directly
into the rig's database, because a `/link` code reaches a player and nobody else. The three minutes
that close it are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
### 22.4 Smaller things worth keeping
- **`MenuAccess.PLAYER` is `isPlayer || isStaff`**, and that is right here: `/player/rust/*` is
`requireAuth` with no role above it, and staff play the game too. The app's own gating test now
asks *which* module a row on a module path declares, not merely whether it declares one — a
second game under `player/` is a new way for that test to have been passing for the wrong reason.
- **The website's pill carries the word, not only the dot.** A filled circle beside a hollow one is
the whole difference between "you have this in game" and "you do not yet". Found by looking at it.
- **The app's row now says when and where an account was linked**, which the website's always did.
Which server minted the code is not part of the identity — a link is fleet-wide — but it is where
a support conversation starts.
- **`/player/rust` has no app deep link**, deliberately: `module-uo`'s player screens have none
either, and the app's web-path table is the *public* nav's. Phase 10 is where it will matter, when
a notification about an entitlement wants somewhere to land.
- **A rig timestamp written by a different connection is five hours out.** The `DATETIME` columns
here are naive, and they round-trip correctly only through the connection that wrote them —
inserting a stand-in row with `docker exec mariadb` made the site read it as the future. Not a
product defect; a rig recipe.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link