Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md.
Four screens on the four routes Phase 14a shipped: the public calendar, an event
page carrying `?run=`, an arc, and participation history. One drawer row for the
history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and
self-scoped, and the website needed two mounts for it only because `RequirePlayer`
guards `/account` there.
The prerequisite fix is the larger half. The app read `/public/modules` nowhere
and mapped every `/public/shard/features` failure to "unknown", which `canSee`
treats as visible — so on a site with no `uo` module every shard row rendered and
every one of them 404'd. Absence of an answer is not an answer of absence: a
successful module list that omits `shard` hides the rows, a failed read keeps the
last answer the host gave, and a host that has never answered leaves the gate
open. Capability and feature compose as two gates and answer different questions:
whether the module is installed (per host) and whether this shard publishes the
surface to this viewer (per viewer).
Also corrects the website path → route table, wrong since the module-system
cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard
rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link
to one handed off to a browser, and the sort-key line was wrong. Two existing
tests had been passing vacuously since that day.
An inbox link to an event now opens the app rather than a Custom Tab, through
`resolveWebPath` rather than a second mechanism — so its "a query hands off" rule
gains exactly one exception, `run` on an event page.
The emulator walk found three defects that 563 green tests did not:
- the three player game-data rows read `/player/shard/*` and were not gated, so
they rendered and 404'd; the test meant to catch that asked whether every row
*with a feature* declared the capability, and those three have none. It now
asks by route.
- `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made
kotlinx refuse the entire body and a 200 rendered as a server error — latent on
the public results table for every visitor.
- a drawer route's view model outlives a sign-out, so signing in as a second
account showed it the first account's participation history with no request
made at all.
570 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4