13 Commits

Author SHA1 Message Date
a4c4476c3e Merge pull request 'docs(modules): the presence fix — nothing names who is online by default' (#262) from docs/rust-presence-fix into main
Reviewed-on: #262
2026-09-23 05:36:05 +00:00
3b7b9cca4f docs(modules): the presence fix - nothing names who is online by default
modules/rust/PLAN.md gains §23: the org lead's rule (2026-09-22) that
nothing names who is online by default, the five decisions it needed
(D42-D46), the fourth surface the rule reached (the leaderboard's
lastSeen), the viewer re-read from the users row, and a defect four
phases old that the walk found - every empty state in module-rust
rendered as a blank box, because core's EmptyState renders children and
the Integration Kit's template teaches title/message.

rust-link/PROTOCOL.md reclassifies six §8.4 kinds from public to
presence - public with an operator-chosen audience - with no wire change.

android/PLAN.md records the app's half as an amendment to M14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 00:32:17 -05:00
eb4a8bcafc Merge pull request 'docs(modules): phase 8 as built, and the rig that put the sidecar where the design says it lives' (#261) from docs/rust-phase-8 into main
Reviewed-on: #261
2026-09-23 01:51:51 +00:00
11b8965aa7 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
2026-09-22 20:35:46 -05:00
15d64b28fe Merge pull request 'docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe' (#260) from docs/rust-phase-7b into main
Reviewed-on: #260
2026-09-22 15:01:26 +00:00
7f008fd1f3 docs(modules): the Carbon rig answers too, and its root is a different directory
R19's byte-identical plugin now carries protocol 5 on both frameworks:
`framework=oxide root=/home/container/oxide/config` on one rig and
`framework=carbon root=/home/container/carbon/configs` on the other, from the
same file. That is R18's amendment proven rather than argued — a literal
`oxide/config/` in this editor would have missed every config on half of all
installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:58:21 -05:00
82145d3b4a docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe
PROTOCOL.md §11 specifies protocol 5: the walk rooted at the framework's own
config directory, the version a write must present back, the set-shaped write,
the watched reload and the automatic restore. PLAN.md §21 is the phase as built
— four org-lead decisions, the float trap and what avoiding it cost, and four
defects a browser found that 179 green tests did not.

PLAYER_WALK.md gains the configuration walk, because the acceptance line needs
the sidecar and the game on one host and the rig cannot reach one yet.
INTEGRATION.md gains the four things an operator should know before they edit a
plugin's settings from a web page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:55:39 -05:00
132205f0f4 Merge pull request 'docs(modules): phase 7 as built — the permission mirror, and the set arithmetic behind it' (#259) from docs/rust-phase-7 into main
Reviewed-on: #259
2026-09-22 06:53:00 +00:00
bba2ab04e0 docs(modules): phase 7 as built — the permission mirror, and the set arithmetic behind it
Protocol 4 (`PROTOCOL.md` §10), phase 7 as built (`PLAN.md` §20), what an operator
needs to know about it (`INTEGRATION.md`), and the in-game leg as a walk to run
(`PLAYER_WALK.md`).

**The spec.** One verb carrying the whole desired set, diffed by the plugin
against the live store; a report whose two interesting fields are the ways a push
looks like it worked and did not (`unresolved`, `pending`); drift as a report
rather than an action; and the permission hooks as a live SIGNAL rather than the
record — a hook that stops firing costs latency, not correctness.

**The finding the design turns on, written where it belongs.** A name in the store
that is not in the desired set is either something the site retired or something a
human granted, and those have opposite correct answers. The store records who
granted a permission nowhere, so only the website can tell them apart — which is
why it keeps a ledger of what it pushed, and why revoking a hand edit needed a
table of its own.

**§10.5 is a rule generalising.** "A wedged sidecar must never stall the game"
becomes "nothing the far side sends may cost the main thread unbounded work",
because `perm.sync` is the first command whose work is not bounded by its own
shape. Three bounds, each on the side that can say something useful when it is hit.

**§20.7 says plainly what is not proven**: the acceptance line needs a second,
non-admin Steam account on the rig, and nothing in the plugin has been compiled.
The walk doc carries the seven steps, including the two things to confirm on
Carbon rather than assume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 22:59:24 -05:00
4fe8864939 Merge pull request 'docs(modules): phase 6 as built — identity, and the sentence a player could not see' (#258) from docs/rust-phase-6 into main
Reviewed-on: #258
2026-09-21 22:24:42 +00:00
e35880e713 docs(modules): phase 6 as built — identity, and the sentence a player could not see
Protocol 3 in PROTOCOL.md §9, the identity walk in PLAYER_WALK.md, what an
operator needs in INTEGRATION.md, and PLAN.md §19.

Seven org-lead decisions (§19.0): /link is chat and its reply is private, codes
live in plugin memory as the UO bridge does, an alphabet with no O/0/I/1, a
Steam id another account holds is refused rather than moved, the website asks
EVERY server because a code does not say which one minted it, staff can sever a
link, and the activity-row overflow belongs to core.

§19.3 is the finding worth reading: a slot router is not registered under a tier,
so this repo own OpenAPI generator described two routes fewer than the module
serves — internally consistent, and wrong. The frozen-manifest job catches it,
which was verified by deleting the two paths and watching it fail.

§19.4 is what a browser found and 122 green tests did not. Core request
primitive reads data.message; this module has answered { error } since phase 1,
so every refusal this phase exists to write rendered as Service Unavailable.

The code-from-the-game half is written down rather than claimed: a code reaches
a player and nobody else, so no console can read one (D27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 09:06:51 -05:00
54b4059091 Merge pull request 'docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given' (#257) from docs/rust-phase-5 into main
Reviewed-on: #257
2026-09-17 09:21:44 +00:00
dfdb0a3f63 docs(modules): phase 5 as built — Android leg A, and the capability a client had to be given
module-rust's surface gets its second client. `modules/rust/PLAN.md` §18 records
the phase; `android/PLAN.md` M14 records the app's half, as every Android
milestone does.

The decision worth the most words is D16. This module declared five capability
strings and every one named a SURFACE — `servers`, `killfeed`, `leaderboard`,
`presence`, `wipes` — while a client gating a navigation group needs one that
names the MODULE. Core flattens every started module's capabilities into a single
list, so `servers` is a word another module could declare tomorrow and silently
reveal these screens on a site that does not run Rust. Gating on the module `id`
was considered and rejected in as many words: `id` is a mount prefix, §2.9
forbids inferring a route from a capability, and letting a client gate on `id`
makes the two the same value in practice.

Also recorded: D17 (poll while RESUMED — the phone's Page Visibility gate), D18
(the Rust repositories move to `edge`, releases at the cutover), D19 (the drawer
badge, and NavPaths learning `/rust`), why D15's footer slot had to be translated
rather than copied, and §18.4's three defects — none of which a green suite of
644 could see, because each is about what a screen looks like or when a number is
re-read.

§18.6 is deliberately short and honest: the website's own pages have still not
been read at phone width, and the badge's non-zero case was shown with a seeded
count rather than by people playing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 03:45:01 -05:00
6 changed files with 1915 additions and 18 deletions

View File

@@ -1307,6 +1307,227 @@ push, and Play (M6M8) follow the designed app.
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
participation history self-scoped, proved by two accounts rather than asserted. participation history self-scoped, proved by two accounts rather than asserted.
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
module** reached its first public pages in `module-rust` phase 4
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
route here existed and answered before a line of Kotlin was written.
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
what the app does about it.
**No backend work beyond one word.** The five public routes were live. The one change is
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
#### Why this is not the shard screens with a different name
The two games have genuinely different shapes, and collapsing them would have cost the app the
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
which case both trees exist at once and neither row appears on a site without its module.
| Screen | Route | Reads |
| --- | --- | --- |
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
#### Four decisions, taken by the org lead on 2026-09-16
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
string, `shard`, because that is the only question a capability can answer: *is the module
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
single list, so gating on `servers` would let another module declaring that word silently reveal
these screens on a site that does not run Rust. Gating on the module **id** was considered and
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
same thing would quietly end that separation. So the module declares its own name as a sixth
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
was closed in engagement Phase 8.
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
#### A refresh is not a load, and the app had only ever done loads
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
failure with rows on screen keeps them and reports the failure beside them; a failure with
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
#### The drawer badge is D15 translated, not D15 copied
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
reader says "42 players online" rather than "42". The count rides there, and keeps the website
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
read keeps the last number, and it never polls. It is asked for only where the module is
installed, so a UO site makes no request at all.
#### Verified
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
that has never reported.
**Both halves of the phase criterion, directly.** With its server unreachable and reading
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
#### The walk found three defects, and 644 green tests found none of them
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
alone, so it was read at connect and never again — which is not what *live* means on a row
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
no padding of its own; each caller pads its own content, and these four did not. On a phone the
first glyph of each line read as clipped.
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
header is the control, and tinting the values says *these are special* instead of *this is what
the table is ordered by*.
#### The rig note worth keeping
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
— not `10.0.2.2`. An emulator walk against a local core therefore needs
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
connect screen reports — correctly, and indistinguishably from a core that is not running.
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
surface. Server configuration, the sidecar token and the connection test are admin
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
and C (phases 8 and 11), and the map is leg D.
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
built and its three decisions. The contract is normative there; this entry records what the app
does about it.
#### It is `CharactersScreen` for a different game, deliberately
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
under the player group**, with the code card at the top of the screen it opens — the same shape
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
module's data would be hard-wired into a core screen).
| Screen | Route | Reads |
| --- | --- | --- |
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
#### The gate is `rust`, and `PLAYER` means staff too
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
D16 gave: a capability answers *is the module there*, and core flattens every started module's
capabilities into one list, so a surface word like `identity` is not something a row may hang
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
`requireAuth` with no role above it, and staff play the game as well.
#### Two reads, and neither blocks the other
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
entitlement is authored against the **website** account, so it exists before a Steam id does, and
the person who has just been given something and has not linked yet is exactly the one who needs
to see both halves at once. The screen says so in as many words when nothing is linked.
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
servers its scope reaches, each already marked *has it* or *waiting*, because a second
implementation of that rule is a second thing to keep true.
#### The refusals stay four pieces of advice
A refusal is chosen by **status** and rendered from a string resource, the convention every
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
reached. The last of those may **not** say "get a new code": the code is still good, and a player
told otherwise goes back to the same unreachable server for another one.
The known cost, written down rather than discovered later: the website distinguishes three 503s
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
app has one string for the status, written to be true of all three.
#### Verified
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
where the backend was talking to a real game server rather than a stand-in.
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
#### The walk found one thing the suite did not
**The row said who, and not when or where.** The website's row has always read "linked just now
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
the identity — a link is fleet-wide — but which server minted the code is where a support
conversation starts, and the module's own schema says so in a comment.
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
admin user-detail screen to put it in, and it writes into a running game — the phone is where
you read what you hold, not where you decide what somebody else holds.
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
Phase 10 is when it will matter, because a notification about an entitlement will want
somewhere to land.
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
Not a milestone of its own: a correction to what M14 shipped, made on the website first
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
default** — the module now withholds the Online list, and every feed item that says a named
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
player **count** stays public.
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
the repository and view model keep the whole answer rather than its rows. The Online tab says
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
check. Branch `fix/rust-presence-visibility`.
### Deferred (not a milestone) ### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is

View File

@@ -982,12 +982,12 @@ Each phase ends with its findings written down, as every workstream here does.
| 2 | **Packaging and release.****Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` | | 2 | **Packaging and release.****Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path, on both frameworks.****Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. 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. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** | | 3 | **The read path, on both frameworks.****Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. 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. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
| 4 | **The first pages.****Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off | | 4 | **The first pages.****Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged | | 5 | **Android leg A** (R10). **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged |
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | | 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). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one | | 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). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin | | 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 | | 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* 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 | | 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 | | 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking | | 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
@@ -2583,6 +2583,832 @@ file and nothing else on the site changes.
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway. viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
## 18. Phase 5 as built — Android leg A, 2026-09-17
The first leg of R10, and the first time this module's surface has had a second client. The app's
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
the phase decided and what the walk found.
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
cores.
### 18.0 Four org-lead decisions
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
every started module's capabilities into one list, so a client gating a whole navigation group on
`servers` would have those screens revealed by any future module that declared the same generic
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
changes, the string a client gates on has to change with it. Module-Rust#5.
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
who is not reading.
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
what makes the cutover the release.
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
added link to `/rust`, hands off to a browser rather than opening the native screen.
### 18.1 D15 has no analogue on a phone, so it was translated
The footer slot works on the web because every public page renders the same footer (§17.4). The app
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
for only where the module is installed, so a UO site makes no request at all.
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
is how many people are on — a server count changes when an operator edits configuration, which is not
news, and is on the page the row opens anyway.
### 18.2 What the app had to grow: a refresh that is not a load
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
| Result | What the reader sees |
| --- | --- |
| It answered | New rows. Nothing else. |
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
at. The leaderboard and the wipe list are never polled at all.
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
last wipe's killfeed under this wipe's heading.
### 18.3 What the walk proved, and how
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
A second core on `:3100` serves `module-uo` and no Rust.
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
board and its wipe history. Nothing on the screen is a live call to a game host.
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
/ Leaderboards / Market and **no Rust row**.
- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A
poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
only in the current wipe — **drops out** of the August board rather than reading zero.
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
with reason and session length; and *while sleeping*.
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
board this server sent. It is offline, so this is who was on then — not who is on now."*
- **The badge**, showing a live count on the drawer row.
### 18.4 The walk found three defects, and a green suite found none of them
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
so it was read when the app connected and never again — which is not what *live* means on a row
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
only by backgrounding the app and returning to it.*
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
not, and on a phone the first glyph of each line read as clipped.
3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left
*Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the
**active sort moved to the header**, because the header is the control: tinting a column of numbers
says *these are special* where tinting the header says *this is what the table is ordered by*.
### 18.5 The rig note worth keeping
The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost`
only** — not `10.0.2.2`. An emulator walk against a local core therefore needs
`adb reverse tcp:<port> tcp:<port>` and the loopback address. Typed as `10.0.2.2`, every request fails
with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
running.
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
an `install -r` over an earlier install keeps the stored base URL.
### 18.6 What is not proven here
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
gave the surface a second client rather than re-reading the first, and the pages have still not been
looked at in a narrow browser.
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
by seeding a player count on the unreachable server and reading the badge before the 30-second
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
## 19. Phase 6 as built — identity, 2026-09-21
R1, and R13's first slot. A player proves a Steam account is theirs by typing `/link` in game and
entering the code on the website; an operator sees the result inside core's own user page.
**Criterion met for everything the site owns, and walked in a browser** — the Steam id, the link
date and per-server all-time totals render under core's security panel on `/admin/users/:id`, with a
staff unlink that writes an audit row. **The in-game half is written down as a walk to run** rather
than claimed: a code reaches a player and nobody else, so no console can read one. It is
[`../../rust-link/PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md)'s new *identity walk*, beside the
read-path walk phase 3 left there for the same reason.
### 19.0 The org-lead decisions this phase needed
Four were taken while it was being built (D20-D23) and three during the walk (D24-D26):
- **D20 — `/link` is a CHAT command, not a console one.** The Rust idiom, and what players expect
from every Discord-linking server they have used. Both frameworks consume a `/` command rather
than broadcasting it and `SendReply` addresses one player, so neither the request nor the code
reaches public chat.
- **D21 — pending codes live in plugin memory, matching `module-uo`.** Asked as "where does an
unconfirmed code live"; answered *"match how it works for the uo module"*, and the UO bridge's
shape was then read out of `BridgeAccountLink.cs` rather than guessed: six characters, five-minute
TTL, thirty-second cooldown, one outstanding code per player. A plugin reload drops them, which
matters because phase 7b's config editor will reload plugins routinely — and the cost is a player
typing `/link` again, which is cheaper than an unconfirmed credential in a second process.
- **D22 — the code alphabet has no O, 0, I or 1.** A player reads it off their screen and types it
into a browser, often on a phone, sometimes reading it to somebody else.
- **D23 — a Steam id another website account holds is REFUSED, never moved**, and the refusal names
the holder. Phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a
silent move is an account takeover performed by typing six characters. The way out is `/unlink` in
game, which the player can reach from the machine they are sitting at.
- **D24 — the website asks EVERY configured server, first `link.ok` wins.** A code is minted by one
server and nothing in it says which. Asking the player to pick was rejected: a wrong pick comes
back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous.
- **D25 — staff can sever a link from the `admin.users.detail` panel**, with an activity row. It is
the counterweight to D23: a player who has lost access to that Steam account in game has no other
route back.
- **D26 — the activity-row overflow (§19.4, defect 3) is CORE's to fix, in its own PR.** A module may
legitimately name an action; shortening this module's names only moves the ceiling to the next one.
### 19.1 What the three repos gained
| Repo | What |
|---|---|
| [Rust-Plugins][rp] | `/link` and `/unlink` chat commands, the in-memory code table with its purge timer, and `link.confirm` — the first command the **website** originates. `checkPlugin.js` grew a check for chat commands, which bind by reflection with the same silent-failure mode as hooks |
| [Rust-Link][rl] | `POST /link/confirm` — the first route on this sidecar that is not a GET. It forwards and nothing else: it does not mint codes, does not store them, and cannot tell a good one from a bad one |
| [Module-Rust][mr] | `rust_account_links`, the fleet loop, the player page at `/player/rust`, and both halves of `admin.users.detail` |
**A refused code is a `200` all the way up.** `link.ok` and `link.error` are both answers; the
sidecar keeps its own status codes for the transport (503 game down, 504 game silent), because the
website has to tell *"that code is wrong"* from *"the game never replied"*.
### 19.2 `admin.users.detail` is declared in three places, and they are three different registries
The slot cost more wiring than its size suggests, and each of the three is held by a different thing:
1. **`module.json`'s `extensions` array** — validated by the loader against the **server** registry.
Naming a client slot there fails the load outright, which phase 1 found the hard way with
`site.footer.status`.
2. **`api.registerExtension('admin.users.detail', router)`** in `server/index.js` — the routes,
mounted inside core's `/api/v1/admin/users/:id` with `mergeParams`. Without that flag
`req.params.id` is `undefined` and every statement in the panel silently scopes to nobody.
3. **`registry.registerExtension(ID, 'admin.users.detail', Component)`** in the chunk — the panel.
Core passes it `userId` and nothing else, so it builds its own client for the routes the server
half registered.
**The gate is core's and it is stricter than the admin tier's.** Core's users router is
`requireRole('admin')` and the slot is mounted inside it, so editors and moderators never reach these
routes — which is right for a surface that can sever what phases 7 and 13 grant against.
### 19.3 The hole the slot found in this repo's own OpenAPI generator
`swaggerFragment.js` ran `register()` against a recording api and walked `record.routes` — the three
tiers. A slot router is not registered under a tier, so **the two routes under `/admin/users/:id`
were generated by nothing**: a fragment that was internally consistent, passed every check in the
repo, and described two routes fewer than the module serves.
A slot's mount is **core's**, so it cannot be derived from anything here — it is a fourth constant
beside `TIER_BASE`, and like `TIER_BASE` it is held to account by a real core in the frozen-manifest
job. That check was verified to catch exactly this, by deleting the two paths from the fragment and
watching it fail.
`test/frozenManifest.test.js` grew the other half. Its *mounts agree* case was written in phase 1
with this phase named in a comment — *"when the slot arrives this test must grow the exception
deliberately, rather than a route outside every declared mount arriving unnoticed"* — and it failed
on the first run after the slot was filled. It now also fails when a **declared slot contributes no
route**, because core never checks that a declared slot was filled.
### 19.4 Three defects the browser walk found, and 122 green tests did not
1. **Every refusal sentence was invisible.** Core's request primitive is the only thing that reads a
module's failures, and it reads one field:
```js
const message = (data && data.message) || res.statusText || 'Request failed'
```
This module has answered `{ error: … }` since phase 1 and got away with it, because until now
every failure landed in `ErrorState` on a page whose whole content was missing — where a generic
sentence is honest. **A form is different: the sentence IS the outcome.** The link page showed
*Service Unavailable* for all four of the refusals this phase exists to write. All 23 error bodies
in the module now answer in `message`, and `test/errorShape.test.js` drives each outcome rather
than grepping for the field.
It is also a correction to phases 1-4, which shipped the wrong shape while referencing core's
`Error` schema — `{ message }` — in their own `#swagger.responses` annotations.
2. **The player saw a stale name.** `/player/rust` showed `Wanderer-old` — the name recorded at link
time — while the admin panel showed `Wanderer`, the name the game last saw. Only the admin read
joined `rust_players`. The same person, labelled two ways on one site, because a Rust name changes
on a whim.
3. **Core's activity row collides with a long action name.** `Dashboard.jsx` renders the action in a
`width: 110`, `flex: 'none'` span with no overflow handling, so `rust.account.unlink.staff`
overlaps the detail text beside it. D26 sends it to core as its own change.
### 19.5 What the walk proved, and how
Against a core at the pinned ref with the module installed, two configured servers (one sidecar up
with no game behind it, one address with nothing listening) and three logins:
- **The criterion**: `/admin/users/2` rendered the Steam id, *linked last month on rust-oxide*, *last
played 12 hours ago*, and per-server all-time totals — 59 kills across two wipes on one server, 3
on another — under core's own security panel.
- **D25**: Unlink removed the row, the panel then rendered *nothing at all* (most users have no Rust
account, and a "no linked accounts" notice on every user page is noise), and
`rust.account.unlink.staff` landed in the activity log naming the operator.
- **The player page**: the link row with its own Unlink, the empty state, and the three-step
instruction that is the only place on the site a player learns the code comes from the game.
- **A refusal that is a sentence**: with both sidecars unreachable, *"The game servers are unreachable
right now — try again in a minute."* — which is what defect 1 above was hiding.
- **Ownership**: a second player deleting the first player's link gets the same `404` as one that does
not exist, so a signed-in stranger cannot discover linked Steam ids by deleting them one at a time.
A player reaching the admin slot route gets `403` from core's own gate.
- **R1's rate limit, live**: ten attempts pass, the eleventh answers `429` with *"Too many link
attempts."* The limiter is per-IP, like core's own login limiter — which means two players behind
one address share the allowance, and that is core's policy rather than a choice made here.
### 19.6 What is not proven here
- **The code from the game.** The rig booted with the phase-6 plugin loaded and announcing protocol 3,
and could not reach the sidecar on the development machine: no inbound firewall rule for TCP 7800
on this Windows host, which is not a change to make from a session. The plugin half's own checks
are green and its shape is the UO bridge's, proven; what is untested is the whole path with a
person in it. **D27 (org lead): it goes in the manual walk document**, as its own *identity walk*
beside phase 3's player walk.
- **`unsure` against a real refusal.** Proving it needs one server that genuinely refuses a code —
which needs a plugin connected — alongside one that is down. The branch is unit-tested and its
sentence was read in a browser; the live combination is step 6 of the identity walk.
---
## 20. Phase 7 as built — site-owned permissions, 2026-09-21
**The site's half is built and walked; the in-game half is written down as a walk to run.** R2 is
the direction the Integration Kit has no chapter for (§2, R2), and building it once is what phase 19
was told to wait for. It is also the first phase where this module writes to a game.
Five repositories touched: `Rust-Plugins#4`, `Rust-Link#4`, `Module-Rust#8` (all into `edge`), this
document, and nothing in `website` — core needed no change, which is itself worth recording after
phases 5 and 6 both found something missing in the contract.
### 20.0 The seven decisions this phase needed
| | Decision |
|---|---|
| **D28** | **A grant is keyed to the website USER**, resolved to every Steam id they have linked at push time. Not to a Steam id: the site authors privilege for a *person*, phase 13's earned entitlements follow whoever earned them, and unlinking an account takes its privileges with it. The cost, which is real and appears again in §20.5, is that a person with two linked accounts holds it on both |
| **D29** | **Every authored row carries a scope** — one server id, or `*` for the fleet. A modded server and a vanilla one will not want one set on both, and a single-server community never sees the choice |
| **D30** | **Groups are mirrored as real groups**, not flattened into per-player grants. Third-party plugins read group membership, R15's BetterChat group API (phase 17) has something to hang on, and an operator reading `oxide.show groups` sees what the website shows. The price is §12.2 rule 4: a player the store has never seen cannot be put in a group, while a direct grant to the same account works immediately |
| **D31** | **A holder the site did not author is reported, never undone**, and an operator is offered two answers: adopt it (the site maintains it from then on) or revoke it (removed on the next sync). A console grant during an incident must survive the next reconcile |
| **D32** | **One verb, and the plugin does the diffing.** The site sends the whole desired set; the plugin compares it against the live store and writes only the difference. Shipping the store to the website was rejected: it is the bigger of the two sets, and a copy of it is a second source of truth that is stale the moment it lands |
| **D33** | **Never self-register a permission.** The form offers what the servers report; a name that stops resolving is reported unresolved and the grant is kept. §12.2 rule 3 says `RegisterPermission` would make it stick with a console warning — doing so fabricates a permission the operator never installed, inert until some plugin happens to check it |
| **D34** | **People and groups, by hand.** Rules that keep themselves true — "everyone in the Donators team is in `vip`" — are genuinely wanted and are a second authority over the same rows, with its own reconcile. Deferred |
### 20.1 Three sets, and every interesting question is a difference between two
```
desired pushed apply
pushed desired RETIRE, because the site put it there and has since withdrawn it
present desired drift
```
The middle row is the whole reason `rust_perm_pushed` exists, and it is the part that cannot be
inferred from anything the game knows. **A name in the store that is not in the desired set is
either something the site retired or something a human granted**, and those two have opposite
correct answers. The store records who granted a permission nowhere, so the site's own memory of
what it pushed is the only thing that tells them apart.
That table is keyed by **Steam id**, not by user, because it records what is in the GAME and the
game has never heard of a website account. Unlinking therefore leaves its rows until the next sync
retires them, which is correct and would be inexpressible keyed the other way.
**Revoking drift needed its own table**, and the reason is a good one: a foreign grant often names a
Steam id no website account holds, so there is no user to author it against and nothing in
`rust_perm_pushed` to remove. `rust_perm_revocations` is an instruction with its own lifetime —
queued by a person, carried in the next sync's `retire` list, deleted when a report comes back. A
server that is offline keeps the instruction until it returns, which is what an operator expects
from a site that claims to be the author of record.
### 20.2 What lands is not what was sent
Two outcomes look exactly like success from the website and are not, and both are reported by the
plugin rather than assumed:
- **`unresolved`** — no loaded plugin on that server registered the name, and `GrantUserPermission`
no-ops silently for one (§12.2 rule 1). This is the finding with teeth that phase 0 produced, and
phase 7 is where it is actually paid for.
- **`pending`** — the store has never seen that player, so a group membership cannot be placed
(§12.2 rule 4). The plugin reads the membership back after adding it, because a void return and no
change is the only signal available.
**Neither is recorded as pushed.** A site that recorded them would believe it had given a privilege
it had not — and would later "retire" it from a server that never had it, a no-op that reads as a
success in every log.
### 20.3 The loop, and what provokes it
Every 30 seconds it compares a digest of the desired set against what each server last confirmed,
and does nothing when they match. A sync happens when the set changed, when the game restarted or
wiped (the boot-id and wipe-id watch), when a `perm.drift` hook arrived, when the last attempt
failed and its backoff has elapsed, or every 15 minutes regardless — the audit is what finds drift
on a server nobody has touched.
**The digest is sorted before hashing.** The rows come out of five queries in an order nothing
guarantees, and an unsorted digest differs between two reads of an unchanged set — which would push
to every game server on every tick, for ever.
`dirty` is an optimisation rather than the truth, and it is written down as such: the loop's real
condition is `desired_hash != synced_hash`, recomputed from the tables every tick, so a flag cleared
while a sync was in flight costs nothing.
### 20.4 Nothing the far side sends may cost the main thread unbounded work
`perm.sync` is the first command whose work is not bounded by its own shape. A community with two
thousand linked players sends thousands of store operations in one frame; applying them in the tick
they arrive is a freeze an operator will blame on the game.
The plugin compiles a sync into single-store operations and drains 200 at a time on a timer,
reporting when the last one lands. Compiling touches nothing, so an oversized or malformed sync is
refused before there is any state to unwind. **This is §5's no-stall rule pointed at the inbound
half**, and it is new: every earlier command was a request to repeat something the game already knew.
The three bounds are each on the side that can say something useful when one is hit — ~15,000 rows
at the website (which can name the server to an operator), 1 MiB at the sidecar (the game link's own
line cap; forwarded, the line is discarded silently and presents as a `504`), 20,000 operations at
the plugin.
### 20.5 Four defects a browser walk found that 133 green tests did not
1. **A person with permissions and no linked Steam account had the whole Rust section hidden** on
core's user page. The section was gated on `links.length`, phase 6's rule, and phase 7 gave it a
second reason to exist — *for exactly the person whose grants reach nobody*, which is the state an
operator most needs to see. It is the same class of defect as phase 6's invisible refusal: a
correct decision that a later phase quietly invalidated.
2. **A member waiting on a first connection looked like an ordinary member.** The count was in the
server strip; nothing was beside the person. Fixed by matching each member's accounts against the
`pending` entries in every server's report — which also required the overview to carry *all* of a
member's Steam accounts rather than whichever one the join returned first.
3. **A grant naming an unregistered permission carried no warning**, though a group's permission
list had carried one from the start. Same fact, two places, one of them missed.
4. The page drew its own `<h1>` under core's chrome title — the same words twice.
**A fifth was found by a test, and it was the better catch.** `buildDesired` resolved a grant's
Steam accounts from the **join** in `listGrants` rather than from the link map, which gives the
right answer by accident: the join repeats a grant per linked account. It would have kept giving the
right answer until somebody changed that query, at which point one of a person's two accounts
quietly stops being granted. The test that caught it was written against a fixture with one row per
grant — the fixture was wrong about the query and right about the model.
### 20.6 What was proven, and how
A real core at the pinned ref with the module installed, the real `rust-link` sidecar, and a
**stand-in plugin** speaking protocol 4 on the loopback — a Node script implementing the store
semantics that matter (unregistered names no-op; an unseen player can hold a grant but not a
membership; `GetPermissionUsers` answers direct holders only).
- a group created, its permission added and two memberships applied on the first sync — two, because
one member holds two linked Steam accounts (D28 on the wire);
- `unresolved: ["kits.gold"]` and `pending: ["7656003:vip"]` reported, and **absent from
`rust_perm_pushed`**;
- a hand edit found as drift, adopted, and the refusal for one whose Steam id belongs to nobody:
*"That Steam account is not linked to any account on this site, so there is nobody to author this
against."*;
- a drift row revoked — queued, carried on the next sync (`revokes: 1`), the queue row cleared;
- a withdrawn grant retired (`pushed desired`), and its pushed row removed;
- **a restart that emptied the store**: `reason: "restart"`, the whole set re-pushed, group,
permission, memberships and grant all back. That is R2's central promise, walked;
- a `perm.drift` frame marking the server dirty through the ingest, and the next sync reporting the
hand edit authoritatively — the live signal and the authoritative answer, in the division §10.4
describes;
- an unreachable second server reporting `transport-error` and retrying on its backoff while the
first stayed in sync;
- the module's chunk alone in the browser console: no second React, no bare import.
### 20.7 What is NOT proven, and who it is waiting on
**The acceptance line — "a grant made on the website gates a third-party plugin in-game" — is not
met**, and the reason was known before the phase started (§12.5): no console session can observe a
permission gate, and an admin account bypasses PopupNotifications and ZoneManager unconditionally.
It needs a **second, non-admin Steam account** on the rig, which is the org lead's to arrange. The
phase was built on the understanding that the in-game leg becomes a walk to run (as D27 did for
identity), not a claim.
**Nothing in the plugin has been compiled.** Two specifics for whoever runs that walk:
- `GetPermissionUsers` and `GetUsersInGroup` answer with `id(name)` and the spacing differs between
the two calls and between the frameworks. The plugin takes everything before the first bracket;
confirm it on both rigs, because a parse that is wrong here reports every holder as foreign.
- `GetGroupPermissions(name, false)` is called with both arguments. If Carbon's signature has no
second parameter this does not compile there — the one place in this change where R19's
"byte-identical plugin" claim is at risk.
### 20.8 Smaller things worth keeping
- **A mount prefix is ONE path segment.** Core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`, so
`/rust/permissions` cannot be declared in `module.json` and has to be a nested `use()` under
`/rust`. The useful half: **swagger-autogen follows the `require` and generates it with the right
prefix anyway** — the exact opposite of phase 6's hole, where the registration walk could not see
a slot router. The generator sees a nested router the walk cannot.
- **This module's first admin page**, and the first thing in it that had to be *authored* rather
than configured. The server rows are still configured through the API and have no screen —
a gap this phase deliberately did not widen into.
- **There is no module-declared site permission at MODULE_API 1.10.0**, so `requireRole('admin')` on
every route is the whole of the available vocabulary. R18's "its own site permission" for the
config editor needs a contract member that does not exist; phase 7b will meet the same wall.
- **Adopting a hand grant widens it.** It becomes a grant against the *person*, so it reaches every
Steam account they hold — one consequence of D28 that only shows up when adopting, and the next
sync applies it to their other accounts. Correct, and worth saying out loud on the screen one day.
- The rig had a leftover core **and** a leftover sidecar from phase 6 still running, and the sidecar
held the release binary open so `cargo build` failed with `Access is denied (os error 5)` and
**exit code 0**. Two sessions' rigs on one machine is now a standing hazard; check ports 3200,
8090 and 7799 before starting anything.
---
## 21. Phase 7b as built — configuration from the site, 2026-09-22
**The site's half is walked end to end against a real sidecar; the plugin's half is proven to
compile and load on a live Oxide rig, and the in-game leg is still blocked on the same firewall rule
phases 6 and 7 hit.** R18 is the second thing this module does *to* a game, and the first that
writes to the game host's filesystem.
Four repositories touched: [`Rust-Plugins#5`][rp], [`Rust-Link#5`][rl], [`Module-Rust#9`][mr] (all
into `edge`) and this document. Core needed no change again — the second Rust phase running.
### 21.0 The four decisions this phase needed
| | Decision |
|---|---|
| **D35** | **The website composes the file's bytes; the plugin writes them.** The generated form sends pointers and literals, a span-splicing editor on the Node side produces the new text, and `config.write` carries whole file text. The alternative — sending key/value edits for Newtonsoft to apply, where C# genuinely can tell `1` from `1.0` — was considered and rejected: it gives the plugin authority over content, puts the decision in a place with no test for it, and still needs the text path for the raw tier. |
| **D36** | **A number travels as the literal an admin typed**, all the way from the browser to disk, and never becomes a JavaScript number. `2.50` stays `2.50`. |
| **D37** | **The raw-JSON tier shows real values, including credentials.** An admin can already read the file over SSH, and a masked raw tier would need the server to splice the originals back into whatever was submitted. The generated form still masks a credential-shaped field behind a *Show* control, and **the audit trail never records the values either way** — a log is read by more people, for longer, and usually by somebody who was not there. |
| **D38** | **The bridge's own config is editable, with three keys locked.** `Host` and `Port` carry the link the edit is travelling over; `ServerId` keys every row this site holds. All three render read-only with the reason; `QueueCap` and anything added later stay editable. Hiding our config entirely was rejected — it reverses D3 — and so was warn-only, because a typo in `Port` orphans the server with no path back. |
### 21.1 The trap, and what it cost to avoid
**JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` is `1` and `JSON.stringify`
writes `1`, while both frameworks deserialize into typed C# classes — so a read-modify-write
silently rewrites every whole-numbered float as an integer *on fields nobody touched*, and Newtonsoft
may coerce that or may throw. A throw at load is a plugin that does not come back, and R6/R17 make
four of them required.
`server/configEdit.js` is the answer: a JSON reader that records the **source span** of every value,
and an `applyEdits` that splices literals into those spans from the end of the document backwards.
Nothing parses, mutates and re-serialises. Everything a save did not touch is byte-identical —
indentation, key order, and the `.0` on a float.
Its suite asserts the failure as well as the fix: the same document through `JSON.parse` /
`JSON.stringify` loses `1.0`, `2.50` and `1e3`, and the test says so in the same breath as proving
the splice keeps them. **A walk on the live rig wrote `2.50` into `ZoneManager.json` and left
`500.0` and a `null` exactly as they were.**
Two rules fall out and both are deliberate: a number's new value arrives as **text** an admin typed,
and the generated form is **type-preserving** — changing what a value *is* belongs in the form,
changing what *kind* of thing it is belongs in the raw tier.
### 21.2 The rollback is the feature, and the window is arithmetic
The plugin backs the set up, writes it, reloads the target through the framework, waits four seconds
for `OnPluginLoaded`, and — if it never arrives — **restores every file, reloads again, and reports
the failure with the tail of the newest log file.** Without that, R18 is a web form that takes a
required plugin off a production server one typo at a time.
Four seconds is not taste. The worst path is two windows — wait, give up, restore, wait again —
while the caller holds a socket, so it has to fit inside the sidecar's 10s `REPLY_TIMEOUT`. The
sidecar mirrors the number as `web::CONFIG_RELOAD_WINDOW` and **a test asserts the inequality**
rather than trusting the comment, in the shape phase 3 established for `budgetMs`.
Walked on the rig: a save whose reload never announced itself came back `200` with
`rolledBack: true`, the file on disk was the original, and the compiler line was on the screen.
**A rollback is a round trip that worked carrying bad news** — reporting it as a 5xx would throw
away the only diagnosis there is.
### 21.3 The bridge will not reload itself
Reloading this plugin would unload it, close the link the answer travels on, and leave a rollback
with nothing watching it: the one failure the whole mechanism exists to report would be the one it
could not. `reload-self` is refused at the plugin, the website leaves it out of the dropdown, and
our own settings apply on the next deliberate reload.
That produced the phase's best defect (§21.5, #1).
### 21.4 What the walk proved, and on what
Two rigs, because the site half and the plugin half fail in different places.
- **The site half** ran against the real sidecar and a stand-in plugin over a real directory of real
config files: the recursive walk (including a nested `Kits/kits.json`), a form save that reloaded,
a save that rolled back, a refusal, a write with no reload, a version conflict with the current
file handed back, and the locked keys. Five outcomes, all five in the audit trail.
- **The plugin half** ran on **both live rigs**, from one byte-identical file. On Oxide 2.0.4143,
1,050 new lines of C# `compiled successfully in 0ms` and the plugin loaded; on Carbon 2.0.259.0 it
loaded in 3,267 ms. `rg.config` answers
`protocol=5 framework=oxide root=/home/container/oxide/config` on the first and
**`protocol=5 framework=carbon root=/home/container/carbon/configs`** on the second.
That is two things at once and the second is the more valuable. `Interface.Oxide.ConfigDirectory`
resolves to a *different directory* on each framework, exactly as R18's amendment predicted — a
literal `oxide/config/` in this editor would have missed every config on half of all installs — and
the runtime framework detection, which the `c.reload` fallback depends on, agrees with the host it
is running on. Neither needed a line of conditional compilation, so R19's claim survives protocol
5.
**The acceptance line is not met**: "an admin flips a ZoneManager setting from the website and it
takes effect" needs the plugin and the sidecar on one host, and the rig's plugin still cannot reach
a sidecar on the development machine — two `Block` rules for `rust-link-sidecar.exe` in Windows
Firewall, the same wall phases 6 and 7 stopped at. It is the same walk each time and it is written
down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
### 21.5 Four defects a browser found that 179 green tests did not
1. **Every save of the bridge's own config was refused**, with the page saying the opposite. The
reload target was seeded from the file's guessed plugin — `RunicGateway` — which is deliberately
*not* offered in the dropdown, and **a `<select>` whose value matches no `<option>` displays the
first one**. So the screen read "nothing — just write the file" while the request carried
`reload: RunicGateway`, and the plugin refused it for a reason the page had just said did not
apply. D38's "editable except three keys" was worth nothing. A guess is now only taken when the
dropdown actually offers it.
2. **`btn ghost` is not a class this platform defines** — core's CSS has `.btn-ghost` — so every
"secondary" button in this module has rendered as a primary one **since phase 7**, and on this
page it meant the open file and the active tier were indistinguishable from the closed and
inactive ones. An unknown class fails silently: the button still renders, just as the wrong one.
Fixed here and on the three pages phase 6 and 7 shipped.
3. **The save's refusal rendered at the top of the page**, while the button that caused it is at the
bottom of a long form — a click that visibly did nothing. It is beside the button now.
4. **Core's module loader cannot see a symlinked module directory.** `loader.js` filters
`readdirSync(..., { withFileTypes: true })` on `isDirectory()`, and a Windows junction is a
symlink, so junctioning the working tree into a rig's `modules/` makes the module silently
invisible. Not a product defect — nothing ships that way — but it costs a confused restart, and
the rig recipe now copies rather than links.
### 21.6 Smaller things worth keeping
- **`config.list` hashes nothing.** A version comes from `config.read`, on the one file somebody
opened. Hashing 500 files would be up to 128 MB of reads in one frame, which is the unbounded
main-thread work §10.5 forbids.
- **The reload target is a field with a guess, never an inference.** A folder name is convention,
not contract: infer it silently and the wrong plugin is reloaded, `OnPluginLoaded` fires for *it*,
and the write reports success while the edited plugin never re-read anything.
- **A file past a limit is listed and marked, never hidden.** An operator who cannot find a file they
know exists goes looking for a bug in the bridge.
- **`rewritten` is normal**: both frameworks merge missing defaults on load and save the file back,
so the file after a good reload is regularly not the file that was sent. The report says so.
- **The version is an FNV-1a hash, not a digest.** Nothing here is a security claim — the website
never computes one, it only echoes back what it was given — and one fewer namespace has to be
available under two plugin compilers.
- **A path from a web form is a traversal surface**, and the guard lives on the host: canonicalise,
assert under the root, refuse absolute paths, drive letters, `..` and reparse points. The sidecar
forwards the path and judges nothing, because only the process holding the directory can decide;
the website checks the *shape* to save a round trip and never pretends that is the boundary.
- **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.
---
## 23. The presence fix — nothing names who is online by default, 2026-09-22
Not a numbered phase: a correction to what phases 4 and 5 shipped, landed **before** phase 9 on the
org lead's instruction. It came out of phase 9's roster question. Asked who may see a clan's roster,
the org lead answered with a rule wider than the phase:
> *Nothing should tell who is online by default — that should always default to the lowest blast
> area, like members and admin. We can have a default online count but not naming players.*
Measured against that rule, the public site had been breaking it since phase 4, in three places: the
server page's **Online** tab named every connected player, the public **feed** carried
`player.connected`/`player.disconnected`, and the Android app (phase 5) rendered both.
As built: [Module-Rust][mr] `fix/presence-visibility` and [Android-app][aa]
`fix/rust-presence-visibility` (both into `edge`). Core needed no change.
### 23.0 The decisions this needed
| # | Decision |
|---|---|
| **D42** | **Nothing names who is online by default.** Staff (admin or moderator) unless an operator widens it. A *count* is public at every setting — it is already on the server list and in the footer, and it names nobody. |
| **D43** | **Every kind that says a named player was on is gated**, not only connects: `player.connected`, `player.disconnected`, `player.respawned`, **`player.death`**, **`player.chat`** and `player.tally`. The org lead chose "gate all of them" over keeping the killfeed and chat public as content, so a default install's public feed carries only the server's own story (a wipe, a start, a shutdown). |
| **D44** | **Three rungs — `staff` · `signed_in` · `public`** — named to line up with phase 14's map-layer switches (public / players / admin) so that one layer can adopt them rather than sit beside them. A "linked players" rung was offered and not taken. |
| **D45** | **A fleet-wide default with an optional per-server override.** A PvE or creative server may reasonably publish a roll call a PvP server must not. A server with no override follows the fleet, so narrowing the fleet narrows every server that never chose otherwise. |
| **D46** | **The fix is its own PR, before phase 9**, not a commit inside it and not deferred to phase 14. |
### 23.1 "Who is online" was wider than the tab of that name
D43 is the org lead's; the fourth surface was found by reading the leaderboard. A leaderboard row
carries `lastSeen`, and a gather tally is flushed every minute a player is on — so a `lastSeen` of
forty seconds ago is the Online tab by another name. It is withheld below the audience too, and the
website drops the column rather than render a row of dashes that read as "never". The rows' **order**
still breaks ties on it, which says who was on more recently and never whether anybody is on now.
### 23.2 The viewer is re-read, not decoded
`ctx.auth.getUserFromRequest` decodes a token and nothing more: the role in it is the role the
account had when it signed in. For a gate on who may see who is online that is not good enough, so
the module re-reads the `users` row through `ctx.users.getById` (MODULE_API 1.1.0, previously unused
here) and takes role and status from it. **Walked:** a moderator demoted in the database mid-session
lost the roll call on the same cookie, on the next request. A banned account reads as anonymous.
Any failure narrows. An unknown stored audience reads as `staff`; a viewer who cannot be resolved
reads as anonymous; a setting that cannot be read withholds rather than answering 500 or publishing.
The asymmetric pair of fallbacks is `module-uo`'s shard-visibility rule, copied rather than
rediscovered.
### 23.3 The wire shape, and why it did not change
`GET /public/rust/servers/:id/online` keeps its shape and adds three fields: below the audience it
answers `{ players: [], hidden: true, count, audience }`. A client that predates the flag renders an
empty list rather than breaking — wrong, but not a crash — and a current one says "2 players online"
and who can see the names. The feed adds `presenceHidden` and `presenceAudience`. All three routes
answer `Cache-Control: private, no-store` with `Vary: Cookie, Authorization`, because a shared cache
that stored a moderator's answer would hand the roll call to the next anonymous visitor.
Settings live in a new `rust_settings` key/value table (phase 9's roster audience is the next key)
and an optional `presence_audience` column on `rust_servers`. `GET/PUT /admin/rust/visibility` is
`requireRole('admin')` over the tier gate, validated whole before anything is written, and every save
is one activity-log row. The admin page is **Rust visibility**.
### 23.4 A defect four phases old: every empty state was a blank box
Core's `EmptyState` renders its **children** and nothing else. This module had passed it `title` and
`message` since phase 4 — the shape the Integration Kit's template teaches (`Clans.jsx`) — and React
drops an unknown prop without a word, so **every empty panel in the module rendered as an empty
box**: "Nobody is on", "No scores yet", "No servers yet", all six of them. It surfaced only because
the presence fix's own message ("2 players online") depended on one; 169 server tests and 39 client
tests could not see it.
Fixed **module-side** on the org lead's steer ("we can't change too much, we do not want to break
other integrations like the UO module"): a small `Empty` wrapper renders a heading and a sentence as
children of core's component, so nothing core or `module-uo` renders changes. A client test now
refuses a titled `EmptyState` — and a `PageHeader subtitle`, the same class of bug the kit shipped in
the Teams work — and was proven by planting an offence and watching it fail.
**Owed to the kit (phase 19):** the template's `Clans.jsx` teaches the wrong prop. Corrected in its
own Integration-kit PR alongside this one.
### 23.5 What was proven, and how
- **API, four viewers × three routes**, against a core at the pinned sha with the module installed
and a stand-in sidecar serving a board with two players and a feed with a join, a death, a tally
and a chat line: anonymous and a signed-in player got `count: 2` and no names, a feed of wipes only
and no `lastSeen`; a moderator and an admin got all of it.
- **The writes:** a moderator's `PUT` 403s; an unknown audience 400s; a request naming one unknown
server 404s and writes nothing; fleet → `signed_in` opened the names to a player and not to
anonymous; a per-server `public` override opened them to anonymous on that server alone; `null`
put it back on the fleet default.
- **The browser**, signed out: the Online tab reads *2 players online · Only this site's staff can
see who they are*; the feed says joins, deaths and chat are not shown; the leaderboard has no
"Last seen" column. The admin page was proven by API only — the walk does not type a password into
a browser — and is left for the org lead's review.
- **The emulator**, signed in as a player: the same two messages at the staff default, and the names
arriving on the next poll after the fleet was widened to `signed_in` — which also proves the app's
bearer session reaches the module's viewer check.
180 server tests, 40 client tests, 652 app tests, `check:imports`, `check:bundle`,
`check:externals`, the regenerated OpenAPI fragment and the frozen route manifest.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
--- ---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link

135
rust-link/INSTALL_RIG.md Normal file
View File

@@ -0,0 +1,135 @@
# The sidecar inside the game container — the Pterodactyl rig recipe
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
is not a phase: it is how the rigs are wired from now on, and it is the shape
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
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.
---
## What goes on the volume
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
| File | What it is |
|---|---|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
| `with-sidecar.sh` | The launcher below, `chmod 755` |
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
Building the binary needs no Rust toolchain on the host:
```bash
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
rustup target add x86_64-unknown-linux-musl
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
```
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
call:** a two-entry `files` array applied only the first, silently, on this panel.
---
## The launcher
```sh
#!/bin/sh
set -e
RL=/home/container/rust-link
mkdir -p "$RL"
export RUSTLINK_CONFIG="$RL/sidecar.toml"
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
export RUSTLINK_DB_PATH
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
eval "val=\${$v-}"
if [ -n "$val" ]; then export "$v"; fi
done
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
exec "$@"
```
Three things in it are load-bearing, and each is a thing that went wrong first:
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
inherits it from this script's environment and still boots modded — which is the composition R20
left unsettled, and this is the answer.
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
container. A `wait` here instead would leave the panel talking to a shell.
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
nothing.
## The startup command
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
the way egg variables will supply them in phase 18 (R22):
```
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
```
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
that survives that, which is why the launcher takes the game command as arguments rather than
containing it.
## Wiring the website to it
The sidecar's web API is on the **second allocation**, so from the site it is
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
`plugin_connected: true` and the protocol version.
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
the container.
## What it proved, first time
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
```
web server listening addr=0.0.0.0:21004
plugin connected peer=127.0.0.1:51148
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
```
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
answered `plugin_connected: true`.
## Still open for phase 18
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
what R22's variable block is for.
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
node, so this is a walk to run, not a claim to repeat.
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
of this, and still the reason a rig can differ from itself between two runs.

View File

@@ -220,6 +220,69 @@ how long the sidecar keeps raw events. The permanent record — per-wipe totals
lives in the website's own tables, so shortening this loses recent detail and never loses a player's lives in the website's own tables, so shortening this loses recent detail and never loses a player's
history. Set it to `0` to keep everything, if the host's disk is yours to spend. history. Set it to `0` to keep everything, if the host's disk is yours to spend.
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
server answers them privately with a six-character code; on the website they type that code in
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
website owns the record, and `/unlink` in game asks it to let go.
Two things an operator should know about it:
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
`account.link.requested`, a staff-visible note that somebody asked.
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
from any of your servers links for all of them. If somebody links the wrong account the site
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
the admin panel.
**From protocol 4 the website owns your permissions.** Groups and grants are written in
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`.
Nothing is required of those plugins and nothing is configured twice.
Four things an operator should know about it, because each looks like something else from the game
side:
- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your
wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the
server being up; ones granted at the console are not, because nothing remembers those.
- **Granting at the console still works, and the website notices.** A hand edit is reported as
drift on that screen and is **never** undone on its own — an operator is offered two answers to
it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console
grant during an incident must survive the next sync.
- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does
nothing for an unknown name, so the site checks first and reports the name as unresolved instead
of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
- **A player who has never connected to that server can hold a grant but cannot be in a group.**
The store has no record of them to put in a group yet; the site says which memberships are waiting
and they land on that player's first connection.
`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell
"that permission does not exist here" from "that player has never been seen here".
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
config. It reads the configuration directory your framework actually uses — `oxide/config` or
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
works for whatever you have installed. Four things worth knowing before you use it:
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
seconds, the old file is **restored automatically** and the site shows you the log line that says
why. A typo costs you a few seconds, not a plugin.
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
next save anyway.
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
assumption.
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
website would cut the link carrying the change, or strand every row the site holds for this
server. Edit them on the host; everything else in that file is editable from the site.
`rg.config` at the server console prints which directory the site is reading and what the last write
from it did.
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and **Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's **the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's

View File

@@ -98,3 +98,145 @@ Not "frames arrived". Three things, and the third is the one worth slowing down
Anything that disagrees with the table is a finding about the game or the framework rather than a Anything that disagrees with the table is a finding about the game or the framework rather than a
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs. mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
---
## The identity walk (protocol 3, phase 6)
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
three steps below.
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
link the same Steam account to.
| # | Do this | You should see |
|---|---|---|
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
account at a time, and every refusal is a sentence that tells the player what to do next.
---
## The permission walk (protocol 4, phase 7)
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
third-party plugin in the game.**
It cannot be walked from a console, and it cannot be walked on the owner's account:
- **A console session bypasses every gate.** The standard idiom is
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer`
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
walked on the owner's account; steps 4 and 5 cannot.
| # | Do this | You should see |
|---|---|---|
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
this phase has two specific things to confirm there rather than assume:
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
differs between the calls and between the frameworks. The plugin takes everything before the first
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
immediately: the drift list fills with grants the site itself made.
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
R19's byte-identical-plugin claim is at risk.
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
operator nothing.
## The configuration walk (protocol 5, phase 7b)
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
live Oxide rig**, where `rg.config` answers
`protocol=5 framework=oxide root=/home/container/oxide/config`.
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
in the running game.** It needs the sidecar and the game server on **one host**, because the game
link is loopback by design (D2).
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
other, which is the whole point of putting them in one container.
| # | Do this | You should see |
|---|---|---|
| 1 | **Open Admin → Rust mod config** and pick the server | The tree the framework actually uses — `oxide/config` on Oxide, `carbon/configs` on Carbon — grouped by plugin, with every loaded plugin's version beside it |
| 2 | **Open `ZoneManager.json`, change a setting, leave the reload target on its guess, and save** | "Saved, and the plugin reloaded." At the console, `oxide.show`/`c.show` is irrelevant — the proof is the plugin behaving differently, so pick a setting you can see: `Auto Show Zones`, or an entry message |
| 3 | **Check a float nobody touched**, e.g. a rate ending `.0`, in the file on the host | It is still `1.0`, not `1`. This is the trap the whole editor exists for, and a server whose configs are full of whole-numbered floats is where it bites |
| 4 | **Break a config on purpose** — in Raw JSON, give a numeric field a string, or anything the plugin's own class cannot deserialize — and save with that plugin as the reload target | Within about four seconds: *"The plugin did not come back, so the old file was put back automatically"*, the compiler's own line underneath it, and the file on the host back as it was. `oxide.plugins` shows the plugin **loaded** — because the restore was reloaded too |
| 5 | **Save a nested file** (`Kits/kits.json`, or any `config/<Mod>/x.json`) **and confirm the reload target** | The right plugin reloads. Reloading the wrong one is the failure this field exists to prevent, and it reports success — so check `oxide.plugins`' timestamps, not the website's word |
| 6 | **Open the bridge's own config** | `Host`, `Port` and `ServerId` are read-only with the reason; `QueueCap` saves; the reload dropdown does not offer this plugin. The save says it was written and **not** reloaded, which is the honest answer — our settings apply on the next deliberate reload |
| 7 | **Edit a file on the host over SSH while the website has it open, then save from the website** | A conflict, with the current file offered — never an overwrite |
| 8 | **Ask for a file outside the tree** (`../data/oxide.users.data`, an absolute path) with `curl` against the sidecar, with a valid token | Refused by the **plugin**, with a reason. The sidecar forwards paths and judges none of them; the guard is where the directory is |
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
this phase has two specific things to confirm rather than assume:
- **The reload path.** The plugin asks `Interface.Oxide` for `ReloadPlugin` by reflection and falls
back to a console command — `c.reload` on Carbon, `oxide.reload` on Oxide, chosen by looking for a
Carbon assembly at runtime. A wrong prefix on Carbon prints **nothing at all**, which looks
exactly like a command that worked (`CARBON.md` §5), so the proof is `OnPluginLoaded` arriving,
not the command being accepted.
- **`OnPluginLoaded` / `OnPluginUnloaded` firing at all.** They are the rollback's only evidence. If
either does not fire on a framework, every save there rolls itself back four seconds later and
reports a plugin that is in fact perfectly fine. `rg.hooks` at the console is the standing answer:
both names are in `ExpectedHooks`, so a framework that never raises one shows a zero.
**What counts as a pass:** a setting typed on the website changes what the running game does; a
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
the save did not touch differs by a single byte.
## The account walk on a phone (phase 8, Android leg B)
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
same three minutes as the identity walk above, done on the phone instead of in a browser.
| # | Do this | You should see |
|---|---|---|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
**What counts as a pass:** a player links an account from the phone without touching a browser, and
the screen never claims an entitlement is in the game when the site has not confirmed it there.

View File

@@ -50,8 +50,8 @@ it is listening without one.
## 2. Versioning ## 2. Versioning
The wire version is a single integer — **2** as of the read path8) — declared in **four** places The wire version is a single integer — **5** as of configuration from the site11) — declared in
that must agree: **four** places that must agree:
| Where | Repo | | Where | Repo |
|---|---| |---|---|
@@ -330,11 +330,12 @@ writing the file and generating the token if they are missing — and prints it
## 7. What is deliberately not here yet ## 7. What is deliberately not here yet
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs Protocol 4 is the transport, the read path, identity and the permission mirror. Every one of these
it, and each is a version bump: arrives with the phase that needs it, and each is a version bump:
- identity and the in-game link code (phase 6) - ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b) - ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
- plugin configuration edited from the site (phase 7b)
- clans, for core's Team provider (phase 9) - clans, for core's Team provider (phase 9)
- leases, budgets and the event actions (phases 12-13) - leases, budgets and the event actions (phases 12-13)
- the map image over the asset-bridge shape (phase 14) - the map image over the asset-bridge shape (phase 14)
@@ -444,12 +445,12 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
| `kind` | Hook | `class` | Carries | | `kind` | Hook | `class` | Carries |
|---|---|---|---| |---|---|---|---|
| `player.connected` | `OnPlayerConnected` | public | steamId, name | | `player.connected` | `OnPlayerConnected` | **presence** | steamId, name |
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec | | `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec |
| `player.respawned` | `OnPlayerRespawned` | public | steamId | | `player.respawned` | `OnPlayerRespawned` | **presence** | steamId |
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid | | `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message | | `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures | | `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker | | `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type | | `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason | | `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
@@ -458,6 +459,8 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced | | `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
| `server.initialized` | `OnServerInitialized` | public | — | | `server.initialized` | `OnServerInitialized` | public | — |
| `server.shutdown` | `OnServerShutdown` | public | — | | `server.shutdown` | `OnServerShutdown` | public | — |
| `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** |
| `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin |
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened `grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
and every community site shows it; a **structure's** grid is where somebody lives, which is why and every community site shows it; a **structure's** grid is where somebody lives, which is why
@@ -483,6 +486,14 @@ So: the table in §8.4 is the specification, `module-rust` holds the allowlist,
its allowlist against this document, so adding a kind here without classifying it there fails a its allowlist against this document, so adding a kind here without classifying it there fails a
build rather than shipping an IP address to a public page. build rather than shipping an IP address to a public page.
**`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23).
The six kinds marked so each say that a *named* player was on the server at a given moment, and the
org lead's rule is that nothing names who is online by default: `module-rust` serves them only to
viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server
override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown).
Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could
be added without a protocol bump.
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and `player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
`player.reported` carries the text of one player's complaint about another. They are stored because `player.reported` carries the text of one player's complaint about another. They are stored because
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
@@ -585,3 +596,502 @@ a game host is a wipe-day outage waiting for a busy month.
--- ---
## 9. Protocol 3 — identity
R1's identity link, and the first message in this bridge that the **website** originates. Everything
in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already
knew.
The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code
and hands it to them privately, and the website redeems it through the sidecar.
```
player plugin sidecar website
│ /link │ │ │
├────────────────────►│ mint code, hold it │ │
│◄────── code ────────┤ in memory, 5 min │ │
│ ├─ account.link.requested ►│ ───── feed ───────►│
│ │
│ ………… the player types the code into the website ……………………………►│
│ │ │◄ POST /link/confirm ┤
│ │◄──── link.confirm ───────┤ │
│ ├───── link.ok ───────────►│ ── steamId, name ──►│
│ │ (code spent) │ │
```
**Nothing about the link is stored in the game.** The site is the author of record, which is not a
preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site
authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game
host would be a second thing to reconcile every wipe, answering no question better.
### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private
`[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and
`SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat.
That is load-bearing rather than polish: **a code read off a stream is a code somebody else can
spend.**
`/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because
the website **refuses** to move a Steam id another account already holds (D23): without a way out, a
player who linked the wrong account while signed in as it would need staff. The authority on that
path is the Steam account itself — whoever is connected to the game as it is who it is.
### 9.2 The code is **not** on the wire
`account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The
event exists so an operator can see linking being used and so the site can see a player fishing; it
is not how the code travels. The code travels **through the player**, which is what makes typing it
into a signed-in browser proof that they are the one who asked.
Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id
beside a website account's activity, and that join — *this player is that person* — is a fact about
somebody's identity rather than about what happened on the server.
### 9.3 `link.confirm` — website → plugin
The first inbound command that is not a request to repeat something.
```json
{ "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" }
```
Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of
`unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`.
**A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal
happens before the expiry check rather than after it, so a code cannot be probed twice.
**`unknown` and `expired` are separate here and identical to the player.** An operator reading a log
wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not
learn which of the two they hit, because that is the difference between "keep guessing" and "guess
faster".
### 9.4 The code itself
Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`**no O, 0, I or 1**, because a player reads
this off their screen and types it into a browser, often on a phone. A five-minute TTL, a
thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one),
and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove
it.
They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase
7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link`
again, which is cheaper than an unconfirmed credential living in a second process.
### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET
```
POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" }
→ 200 { "kind": "link.error", "reason": "unknown" }
→ 503 the game is not connected
→ 504 the game is up and did not answer
```
**A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its
own status codes for the transport, because the website has to tell *"that code is wrong"* from
*"the game never replied"* to say the right thing to a player (§4.3).
The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it.
Only the game holds the pending codes, and putting the table here instead would give the sidecar a
credential and an opinion, which D2 and the bridge principles say it has neither of.
### 9.6 The website asks EVERY server (D24)
A code is minted by one server, and the player types six characters into a browser. Nothing in the
code says which server it came from, so the module asks each configured server in turn and the first
`link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent
at the server that holds it.
Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code.
The consequence for this protocol is worth stating, because it is the shape of every later
fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not
be reached"**, and a module that collapses them tells the player whose server is down that their code
is wrong — so they fetch another code from the same server and hear it again.
---
## 10. Protocol 4 — the permission mirror
R2, and the first command on this bridge that **changes the game**. Protocol 3's
`link.confirm` was the website originating a message, but it spent a code the game
itself had minted; this writes to a store the game enforces.
```
website sidecar plugin
│ │ │
├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the
│ the whole desired set │ (the same object) │ live store, apply
│ │ │ the difference in
│◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps
│ │
│◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote
```
**The website is the author of record and the framework's store is an enforcement
cache.** Every third-party plugin honours a site grant with no adapter, because
they all already call `permission.UserHasPermission` — reaching them is the point,
and it is why the site does not keep a private table of its own.
### 10.1 One verb, and the PLUGIN does the diffing
`perm.sync` carries the whole set the site authors **for that server**. The plugin
compares it against the live store and writes only what differs.
The alternative — the plugin reporting its store and the website computing the
difference — was rejected for two reasons. The store is the bigger of the two sets
and would cross the wire constantly, and a website holding a copy of it has a
second source of truth that is stale the moment it lands.
```json
{
"cmd": "perm.sync",
"reqId": "r-42",
"setId": "69dfc769…",
"groups": [
{ "name": "vip", "title": "VIP", "rank": 10,
"permissions": ["kits.vip"],
"members": ["76561198000000001", "76561198000000002"] }
],
"grants": [
{ "steamId": "76561198000000001", "permissions": ["kits.gold"] }
],
"managed": ["kits.vip", "kits.gold"],
"retire": [
{ "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" }
]
}
```
| Field | Means |
|---|---|
| `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one |
| `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently |
| `grants` | permissions held by one account without a group |
| `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store |
| `retire` | what the site put there and has since withdrawn (§10.3) |
### 10.2 `perm.report` — what actually happened
```json
{
"kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…",
"applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1,
"members": 2, "membersRemoved": 0, "groupsRemoved": 0,
"groupPermissionsRemoved": 0 },
"alreadyCorrect": 14,
"absent": 0,
"unresolved": ["kits.gold"],
"pending": ["76561198000000003:vip"],
"foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }],
"operations": 4
}
```
**`unresolved` and `pending` are the two ways a push looks like it worked and did
not**, and both are load-bearing:
- **`unresolved`** — no loaded plugin on that server has registered the name.
`permission.GrantUserPermission` returns void, throws nothing and logs nothing
for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so
without the `PermissionExists` pre-check the grant vanishes without a trace. The
plugin does **not** register the name itself: that fabricates a permission the
operator never installed.
- **`pending`** — the store has never seen that player, so there is no user record
to put in a group (§12.2 rule 4). A **direct grant** to the same account works
immediately, and the asymmetry is exactly why groups are not the only shape the
site can express. The membership lands on their first connection.
Neither is recorded by the website as pushed. A site that recorded them would
believe it had given a privilege it had not — and would later "retire" it from a
server that never had it, which is a no-op that reads as a success in every log.
**A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an
earlier sync is still draining) or `too-large`. Like `link.error` it is a `200`
from the sidecar: the transport worked and the game answered.
### 10.3 Retirement is the one thing the game cannot work out
A name in the store that is not in the desired set is **either** something the site
authored and has since withdrawn **or** something a human granted at a console —
and those two have opposite correct answers. The store records who granted a
permission nowhere, so only the website can tell them apart, from its own memory of
what it pushed.
So the site sends `retire` explicitly, and everything else it did not ask for comes
back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a
console `oxide.grant` during an incident is drift, not an error, and an operator is
offered two answers to it on the website — adopt it, or revoke it.
### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation
Both frameworks raise a hook for every permission write. The plugin subscribes to
six of them and emits `perm.drift` for writes **it did not make itself**, staff
class (§8.5): it names a Steam id beside a privilege, which is a fact about a
person's standing rather than about what happened on the server.
```json
{ "kind": "perm.drift", "type": "event", "action": "granted",
"steamId": "76561198000000009", "permission": "kits.admin" }
```
`action` is one of `granted`, `revoked`, `group-added`, `group-removed`,
`group-permission-granted`, `group-permission-revoked`.
**It cannot say whether the change is foreign** — only the desired set can, and
that comparison happens in a sync. So the website treats the frame as a reason to
reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and
the authoritative answer still arrives as a report. That division is what makes the
hooks safe to trust at this weight: one that stops firing on a framework upgrade
costs latency, not correctness.
The plugin suppresses them while it is applying a sync, because they fire for its
own writes too — and the site cannot tell its own grant from a human's by looking
at one.
### 10.5 Nothing the far side sends may cost the main thread unbounded work
This is the first command whose work is **not** bounded by its own shape. A
community with two thousand linked players sends thousands of store operations in
one frame, and applying them in the tick the frame arrives is a freeze an operator
will blame on the game.
So a sync is compiled into a list of single-store operations and drained a few
hundred at a time on a timer; the report goes back when the last one lands.
Compiling touches nothing, so an oversized or malformed sync is refused before any
state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar
from stalling the game — pointed at the inbound half.
Three bounds, each on the side that can say something useful when it is hit:
| Bound | Where | Why there |
|---|---|---|
| ~15,000 rows | the website | it can name the server and reach an operator |
| 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` |
| 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about |
### 10.6 `GET /permissions/catalogue`
A live round trip to the plugin: every permission the loaded plugins have
registered, and the groups the store holds. It is the option source behind the
website's authoring form — a grant can only be written against a name that will
actually resolve — and, like `/status`, it fails when the game is down, because
"what exists right now" has no stale answer worth giving.
### 10.7 What the sidecar does NOT do
It defines no schema for either body. Protocol 4 adds the largest command on this
bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder
property paying for itself a second time.
What it does own is the envelope: `cmd` and `reqId` are written over whatever the
caller sent, so no request can arrive claiming to be a different command or aimed
at a correlation id somebody else is waiting on.
---
## 11. Protocol 5 — configuration from the site
R18, and the first command on this bridge that writes to the game host's
**filesystem**. Protocol 4 wrote to a store the game owns through an API the game
owns; this replaces bytes in a file and then asks the framework to read them.
```
website sidecar plugin
│ │ │
├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory
│◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory)
│ │ │
├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version
│ │ │
├── POST /config/write ────►│ ──── config.write ──────►│ back up, write,
│ whole file TEXT │ │ reload, WATCH
│◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all
```
**The website composes the bytes and the plugin writes them.** That split is the
one design decision everything else here follows from, and §11.5 is why.
### 11.1 The roots come from the framework, and one of them is forbidden
The walk is rooted at `Interface.Oxide.ConfigDirectory``oxide/config` on
Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved
it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is
never composed from a literal, and that amendment was proven the best way it
could have been: this bridge's own config landed in **both** places, written by
the same source file.
`DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone
definitions — and both frameworks' own permission stores (`oxide.users.data`,
`oxide.groups.data`), which is protocol 4's mirror one directory over. A
settings editor that strayed there would be editing §10 underneath itself.
### 11.2 `config.list` — a description of the tree, never its contents
```json
{
"kind": "config.catalogue", "type": "reply", "reqId": "r-7",
"root": "/home/container/oxide/config",
"self": "RunicGateway",
"files": [
{ "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000,
"plugin": "ZoneManager", "editable": true },
{ "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000,
"plugin": "Kits", "editable": true },
{ "path": "Huge.json", "bytes": 9400000, "editable": false,
"reason": "larger than this bridge will carry" }
],
"plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ],
"truncated": false,
"limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 }
}
```
Four things about that shape are load-bearing.
**No file is hashed here.** A version is produced by `config.read`, on the one
file somebody actually opened. Hashing 500 files would be up to 128 MB of reads
in a single frame, which is the unbounded main-thread work §10.5 forbids — so
this walk reads directory entries and nothing else.
**`plugin` is a GUESS and is labelled one all the way to the form.** It is the
folder for a nested file and the filename otherwise, and a folder name is
convention rather than contract. Infer it silently and the failure is the
nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for
*it*, and the write is reported as a success while the plugin that was actually
edited never re-read anything.
**A file past a limit is listed and marked, never hidden.** An operator who
cannot find a file they know exists goes looking for a bug in the bridge; one who
can see why it was refused does not.
**`self` is the plugin naming itself**, so the website can lock the three keys in
*our* config that would cut this link (§11.6) without matching on a filename
somebody may rename.
### 11.3 `config.read` — one file, and the version a write must present back
```json
{ "kind": "config.file", "type": "reply", "reqId": "r-8",
"path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}",
"version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 }
```
`version` is the file's length and an FNV-1a hash of its text. It is deliberately
**not** a cryptographic digest: nothing here is a security claim — the website
never computes one, it only echoes back the one it was given — and
`System.Security.Cryptography` is one more thing that would have to be available
under two plugin compilers.
### 11.4 `config.write` — the set, the reload, and the undo
```json
{ "cmd": "config.write", "reqId": "r-9",
"files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ],
"reload": "ZoneManager" }
```
The plugin, in order:
1. resolves and guards every path (§11.6), checks every version, and checks that
every document parses — **before the first byte is written**. Same posture as
`perm.sync`: a refusal that has touched nothing has nothing to unwind;
2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping
the last ten per file, and holds the original in memory for the rollback;
3. writes the set;
4. reloads the named plugin **through the framework**, not by composing a console
string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is
opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly
like a command that worked;
5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin;
6. if it arrives, re-reads each file and reports the new versions. If it does
not, **restores every file, reloads again, and reports the failure with the
tail of the server's newest log file.**
```json
{ "kind": "config.report", "type": "reply", "reqId": "r-9",
"ok": false, "reloaded": false, "rolledBack": true,
"reason": "'ZoneManager' did not reload within 4s",
"log": "…Error while compiling ZoneManager…",
"files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] }
```
**That rollback is the feature.** Without it this is a web form that takes a
required plugin off a production server one typo at a time — and four plugins are
required (R6/R17), so a broken `ZoneManager` config is also event participation
gone.
Three consequences worth naming:
- **The window is arithmetic, not taste.** The worst path is two windows — wait,
give up, restore, wait again — and the caller holds a socket throughout. It
must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback
report arrives after the only thing waiting for it has gone. The sidecar
mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the
inequality rather than trusting it.
- **`rewritten` is normal.** Both frameworks merge missing defaults into a config
on load and save it back, so the file after a successful reload is regularly
not the file that was sent. The report says so; a website that assumed
otherwise would conflict with itself on the next save.
- **The bridge will not reload itself.** The reload would unload this plugin and
close the link carrying the answer, leaving a rollback with nothing watching
it — the one failure the mechanism exists to report would be the one it could
not. `reload-self` is refused, and our own settings apply on the next
deliberate reload instead.
### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number
`JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both
frameworks deserialize a config into typed C# classes, so a naive
read-modify-write **silently rewrites every whole-numbered float as an integer,
on fields nobody touched** — and Newtonsoft may coerce that or may throw. A throw
at load is a plugin that does not come back.
So the website never parses, mutates and re-serialises. Its editor records the
**source span** of every value and splices new literals into them, which is why
`config.write` carries whole file text: the bytes on the wire are the bytes that
will be on disk, and the fields nobody edited are byte-identical. A number's new
value travels as the literal an admin typed, and never becomes a JavaScript
number anywhere in the path.
The plugin's contribution to that is deliberately nothing beyond checking that
the document parses. Giving this end an opinion about content would put the
decision in two places, and only one of them can be tested against a real
Newtonsoft.
### 11.6 Addressing by path is a new bug class, and it is guarded here
Protocol 4 addressed things by name. This addresses them by path, which is
exactly the change that introduces traversal — so the plugin refuses a path that
is absolute, carries a drive letter, contains `..`, does not end in `.json`, or
does not resolve **under the canonicalised config root**. Links are not followed:
any file or directory carrying a reparse point is skipped by the walk and refused
by the resolver, because resolving one is how a tree that looks bounded turns out
not to be.
The sidecar forwards the path verbatim and judges nothing, as it forwards a link
code and a permission set. That is not laziness: only the process holding the
directory can decide whether a path resolves inside it, and a guard in the middle
would be a weaker second opinion in a place with no way to check it.
The website checks the *shape* before spending a round trip, and the bridge's own
three keys — `Host`, `Port`, `ServerId` — are refused there rather than here,
because "which file is ours" is a question about the website's configuration, not
about the game's.
### 11.7 What the sidecar does NOT do
It stores nothing. Nothing from protocol 5 reaches the store or the feed: a
config this sidecar cached would be an edit an operator made over SSH that the
website then silently overwrote. All three routes fail when the game is down,
like `/status`, because "what is on that host's disk" has no stale answer worth
giving.
The one thing it adds is a better `504`. A timeout on `/config/write` is the only
timeout on this bridge with a knowable answer, because the plugin writes a whole
set or restores a whole set and never half of either — so the body says to
re-read rather than to guess, and names the reload window that is probably still
running.
---