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

@@ -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