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
This commit is contained in:
2026-09-17 03:45:01 -05:00
parent 2f24236993
commit dfdb0a3f63
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