5 Commits

Author SHA1 Message Date
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
2f24236993 Merge pull request 'docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found' (#256) from docs/rust-phase-4 into main
Reviewed-on: #256
2026-09-17 02:42:51 +00:00
788100e048 docs(modules): phase 4 as built — the first pages, and the four defects a browser walk found
Records PLAN.md section 17 and marks the phase row done. Four decisions (D12-D15),
what is on the pages, and the four defects only a live walk could find — two of
them in code phase 3 had already shipped: an unreachable refresh that erased the
server's description, and a "last reported" line reading the timestamp of our own
poll rather than of the server's last frame.

Also written down: why core's useAsync cannot poll, the real per-page cost of a
live count in the footer slot, and the test fake that was *nearly* core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 21:40:39 -05:00
5 changed files with 755 additions and 6 deletions

View File

@@ -1307,6 +1307,128 @@ 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
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.
### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is

View File

@@ -981,9 +981,9 @@ Each phase ends with its findings written down, as every workstream here does.
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.****Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
| 2 | **Packaging and release.****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** |
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | 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 |
| 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 |
| 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). **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). ✅ **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 |
| 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 |
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
@@ -2417,6 +2417,472 @@ on a shared rig something usually does.
because the sidecar under test was an older `cargo build`; `clippy` and `test` compile without
writing one. Rebuild before believing a rig.
## 17. Phase 4 as built — the first pages, 2026-09-16
One repository, and the first phase whose whole deliverable is something a visitor looks at. It
consumes exactly the routes phase 3 built and adds one of its own; nothing here talks to a game
server, which is the point of the criterion it was written against: **the site renders the last
thing each server said while every server is off.**
**Status: criterion met, and met the hard way.** The pages were walked in a browser against a live
rig — a real Rust server behind the real sidecar for `main`, and a second, deliberately unreachable
server for everything the record holds. That walk found four defects, two of them in code phase 3
had already shipped, and all four are fixed here (§17.2).
### 17.0 The four decisions this phase needed
- **D12 — `/rust` is the server list.** Phase 1 registered it at `/rust/servers` and left the
module's own namespace root answering core's CMS catch-all. R8 calls the list "the landing page",
so it is registered with an **empty path** — core renders that as `/rust` — and the detail page
hangs beneath it at `/rust/servers/:id`. One canonical address, and it is the module's name.
- **D13 — one page with in-page tabs**, not four routes. `/rust/servers/:id` carries a header and
four panels (feed, leaderboard, online, wipes). A tab strip is not in the shared UI kit, so the
module bundles its own — which is the kit working as designed rather than a gap in it (§3.4 is a
closed list of nine members, and everything above them is the module's).
- **D14 — poll while the tab is visible.** The feed and the presence list re-fetch every twenty
seconds, paused by the Page Visibility API and refreshed the instant a viewer comes back. The
leaderboard and the wipe list load once: a table that re-sorts itself under the reader's cursor is
worse than one four minutes old. Server-Sent Events were considered and are not this phase — core
has a fan-out, but the module registers no stream, and a cursor-driven one is phase scope.
- **D15 — the footer slot carries a live count**, not a static link: `2 servers · 42 online`, linking
to `/rust`. The cost is stated rather than assumed — see §17.4.
### 17.1 What is on the pages
`/rust` is the list: name, map, size, when it was wiped, when it last reported, and the player count
or `Offline`. The whole row is the link.
`/rust/servers/:id` is the server. The header is what a Rust player asks first — map, world size,
seed, wipe date — with live status beside it and a wipe selector that applies to the whole page.
Then four tabs:
| Tab | Reads | Refresh |
|---|---|---|
| Feed | `…/events`, with a filter that maps to the `kind` parameter | 20s, visibility-gated |
| Leaderboard | `…/leaderboard`, sortable, per wipe or all-time | on mount |
| Online | `…/online` — the presence **board**, not counted transitions | 20s, visibility-gated |
| Wipes | `…/wipes`; picking one filters the feed | on mount |
**Everything selectable is in the URL** — tab, filter, wipe, sort. That costs a little ceremony in
the page and buys the thing a community site is for: *"last wipe's leaderboard on Main"* is a link,
the back button undoes a click rather than leaving the page, and a refresh lands where the reader
was. `wipe=current` is a word rather than an id on purpose, so a shared link stays about now.
**The feed renders parts, not sentences.** `lib/feed.js` turns a stored frame into
`{tone, actor, join, verb, subject, detail}`, which keeps the names emphasised without any HTML in a
string, and makes the whole thing testable in a runner with no DOM. A death is four sentences, not
one — `player`, `self`, `npc`, `environment` — because the plugin distinguishes them so a reader does
not have to guess, and a fall reported as a kill by nobody is the failure that avoids. **A kind this
build has never heard of renders as itself** rather than vanishing: the server's allowlist has
already decided the row may be seen, so what is left here is presentation, and the honest
presentation of a kind we have no words for is its own name.
**`player.tally` is public and deliberately not in the feed.** It is an aggregate the plugin flushes
once a minute per active player (PROTOCOL.md §8.6), so a feed carrying it would be mostly wood
counts. It is the leaderboard's input, and that is where it shows up.
### 17.2 Four defects the page walk found, and two of them were already shipped
The walk was the whole point of doing one. None of these is visible in a test that stubs a sidecar
which answers.
1. **An unreachable refresh erased what the server last said.** Phase 3's refresh loop called
`putState` — the whole-row write — with two fields when a sidecar did not answer, so `hostname`,
`level`, `seed`, `world_size` and `wipe_id` all went to NULL the first time a game host rebooted.
The list then read `Offline` with nothing beside it, which is not *"here is what we know about a
server that is down"*, it is *"we have never heard of it"* — and it defeats this phase's
criterion exactly. Fixed with `markUnreachable`, which moves three columns and mentions no
others; `server/test/refresh.test.js` asserts against the SQL, because the defect is about which
columns a statement names.
2. **"Last reported" was reading the wrong timestamp.** `updated_at` is when this module last WROTE
the row, which a failed poll does too — so an offline server claimed it had reported just now,
every thirty seconds, for as long as it stayed down. They are two facts and both are wanted:
`updated_at` decides staleness, and a new `last_seen_at` records when a `server.hello` last
arrived. Only a successful refresh moves it.
3. **Every feed row showed a bare time of day.** Correct for today's killfeed and wrong the moment
the feed is filtered to a past wipe: three events from six weeks ago all rendered as `02:03 PM`.
Rows from another calendar day now carry the date. The boundary is the calendar rather than a
duration, because that is what a reader means by "what time was that".
4. **A mistyped address was dressed as a fault.** The detail page rendered core's `ErrorState` under
its own heading, so `/rust/servers/typo` read "No such server / Something went wrong" and sent a
reader looking for an outage. A 404 is now its own answer and `ErrorState` is kept for a request
that failed for a reason nobody can see.
A fifth, smaller: the Online tab listed three players under a header reading `Offline`. An
unreachable sidecar does not clear the presence board — deliberately, the rows are still the best
answer anybody has — but presented bare they read as *who is on right now*, which is the one thing
an offline server cannot be saying. The panel now says which it is.
### 17.3 `useAsync` cannot poll, and that is not a defect in it
Core's fetch hook (§3.4) blanks `data` and sets `loading` on every dependency change. That is right
for a page load and wrong for a poll: bumping a dependency every twenty seconds would clear the
killfeed, render a spinner in its place and re-fill it, four times a minute, for ever.
So the module bundles `hooks/usePolled.js`: a refresh that is **invisible when it succeeds** and
keeps the rows *and* reports the error when it fails — because a site whose premise is "it renders
while the game is off" must not blank itself the first time a request does. `key` (the question)
resets the data; the interval does not. `useAsync` is still the right hook for everything that loads
once, and both are used here.
The live proof: with the tab hidden the log shows no requests at all, and the instant it became
visible there was one refresh followed by one every 20.0 seconds.
### 17.4 The footer slot's real cost, stated
Core renders `SiteFooter` inside `PublicLayout`, and **every public page renders `PublicLayout`
itself** (§3.3) — so a component in that slot mounts once per public page view, not once per
session. D15's live count therefore puts one `/public/rust/servers` request on every public page of
the site, including pages with nothing to do with Rust.
Two things keep that honest rather than merely cheap. It **renders nothing until it has an answer,
and nothing at all if the request fails** — core's `<Slot wrap>` takes its separator with it, so a
failure degrades to exactly the footer an instance with no module installed has. And it **never
polls**: one request per page view is a cost; a timer in the footer of every page is a different
kind of thing. If it ever shows up in an operator's logs, the fix is a short-lived cache in that one
file and nothing else on the site changes.
### 17.5 Smaller things worth keeping
- **The registration fake was *nearly* core, which is worse than obviously not.** `client/test`'s
fake registry prefixed routes as `` `${id}/${path}` ``; core strips the trailing separator too,
which is exactly what lets a module register `path: ''` and own its namespace root. The day a
module did, the fake produced `rust/` where a real core produces `rust`, and the suite failed the
nav check for a link that works perfectly in a browser. The fake now copies core's line character
for character.
- **A SQL comment inside a JS template literal may not contain a backtick.** Obvious written down,
invisible while writing prose about `markUnreachable` inside a query string; the file stops
parsing several lines later and the error names an argument list.
- **The detail route exists so that a page can 404.** Every other route under `/servers/:id` answers
an empty list for an id nobody configured — an unknown server genuinely has no events, no
leaderboard and nobody online, and each of those is a good answer to its own question. Only
`GET …/servers/:id` can say the server is not there. A disabled server answers the same 404 as a
missing one: an operator who switched a server off did not switch it into a 403.
- **`capabilities` grew to what the pages serve** — `servers`, `killfeed`, `leaderboard`,
`presence`, `wipes` — which is the list phase 5's Android leg feature-detects against.
- **The rig had two cores again**, and this time the mechanism was visible rather than inferred: a
leftover core holding an older module release spoke protocol 1, was refused `409`, and rewrote the
state row as unreachable every thirty seconds — the phase-3 trap, and also how defect 1 above was
found. One of the two was stopped with the org lead's say-so before the walk continued.
### 17.6 What was proven, and how
- **The criterion, directly.** A second server was configured pointing at a dead address and seeded
with a fixture shaped exactly as the plugin emits (two wipes, 26 events, four players, a presence
board). With that server unreachable and reporting `Offline`, its page still rendered its map,
size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, last known board and wipe
history. That is the phase criterion in one screenshot.
- **The allowlist, on real rows.** The fixture includes a kind this build has never heard of. The
public route answered `{"events":[]}` for it when asked **by name** — a refusal, not a filter.
- **R12's arithmetic, on the page.** All-time equals the two wipes summed (41 + 18 = 59), and a
player who only appears in the older wipe drops out of the current one rather than reading zero.
- **The footer slot**, live in core's own footer on every public page, linking to `/rust`.
- **The chunk's identity check**, which is the one failure only a browser can show: the console
carried the module's own registration line and nothing else — no second React, no bare import.
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.
## 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.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link

View File

@@ -220,6 +220,21 @@ 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
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.
**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
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's

View File

@@ -98,3 +98,32 @@ 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
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.

View File

@@ -50,8 +50,8 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **2** as of the read path8) — declared in **four** places
that must agree:
The wire version is a single integer — **3** as of identity9) — declared in **four** places that
must agree:
| Where | Repo |
|---|---|
@@ -333,7 +333,7 @@ writing the file and generating the token if they are missing — and prints it
Protocol 2 is the transport plus the read path. Every one of these 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)
- clans, for core's Team provider (phase 9)
- leases, budgets and the event actions (phases 12-13)
@@ -458,6 +458,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.initialized` | `OnServerInitialized` | 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
and every community site shows it; a **structure's** grid is where somebody lives, which is why
@@ -585,3 +587,118 @@ 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.
---