2 Commits

Author SHA1 Message Date
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
2 changed files with 271 additions and 1 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

@@ -982,7 +982,7 @@ 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` |
| 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 |
| 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 |
| 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 |
@@ -2583,6 +2583,154 @@ 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
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.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link