docs(android): the app's events screens, and the module rows that were never gated (Phase 14b)
Events Phase 14b, docs half. `docs/android/PLAN.md` gains **M13** — written before the code, as the plan requires — and `EVENTS_PLAN.md` and `EVENTS.md` record 14b as built. M13 records the six org-lead decisions, the two defects the milestone fixes, and the three the emulator walk found. The prerequisite fix is the larger half of the milestone: the app could not tell a module that is not installed from a lookup that failed, 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 the string 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 are two gates that compose and answer different questions. A second, older defect is recorded and fixed: the website path → route table had been wrong since the module-system cutover on 2026-08-12, because core's `NAV` is eight rows rather than sixteen and the nine shard rows moved to `/uo/*`. Two tests in the app's suite had been passing vacuously since that day. §5 gains the capability gate beside the feature gate, with the two failure directions spelled out; §6.1 gains the three public event reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
179
android/PLAN.md
179
android/PLAN.md
@@ -661,6 +661,8 @@ not rank).
|
||||
| Contact | everyone | `/public/contact` |
|
||||
| **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) |
|
||||
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
||||
| **Events** | everyone, *if core serves events* | `/public/events`, `/public/events/:slug`, `/public/events/series/:slug` (M13) |
|
||||
| **My Events** | signed-in, any role | `/player/events/history` (M13) |
|
||||
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |
|
||||
|
||||
Guidelines:
|
||||
@@ -672,6 +674,15 @@ Guidelines:
|
||||
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
|
||||
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
|
||||
a link that briefly `403`s.
|
||||
- **Nor is it the only gate on a MODULE's surfaces (M13).** A feature flag says whether a shard
|
||||
publishes a surface to this viewer; it cannot say whether the module that owns it is installed at
|
||||
all. `GET /public/modules` and core's own `capabilities` in the `version` block answer that, per
|
||||
host rather than per viewer, and an entry may declare a capability alongside its feature. The two
|
||||
compose and both must pass. **The two failure directions differ, and deliberately:** an unknown
|
||||
*feature* answer shows everything, because the server gates every call regardless; an unknown
|
||||
*capability* answer shows everything too, but a **known** one that does not name the capability
|
||||
hides the rows — absence of an answer is not an answer of absence. Without that distinction a site
|
||||
with no `uo` module renders five shard rows that each `404`.
|
||||
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
|
||||
groups and a "Sign in" affordance.
|
||||
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
|
||||
@@ -692,6 +703,11 @@ Guidelines:
|
||||
- **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses).
|
||||
- **Wiki** — list/categories/tags/detail as above.
|
||||
- **Contact** — `POST /public/contact` (rate-limited; handle 429/502).
|
||||
- **Events (M13)** — `GET /public/events` (the calendar; entries are a materialised `run` or a
|
||||
`projected` forecast and say which), `GET /public/events/:slug` (one event, `?run=` selecting
|
||||
which occurrence a results table is about), `GET /public/events/series/:slug` (an arc). Gated by
|
||||
core's `events` capability, not by a module's. A draft, an archived and an *unlisted* definition
|
||||
all answer `404`, indistinguishable from a slug that never existed.
|
||||
|
||||
### 6.2 Public shard (live)
|
||||
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
|
||||
@@ -1128,6 +1144,169 @@ push, and Play (M6–M8) follow the designed app.
|
||||
rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was
|
||||
discarding three 98–100%-covered resolvers. **477 tests green.**
|
||||
|
||||
14. **M13 — Events in the app** (post-v1; scoped 2026-09-08). The website's Event System reached its
|
||||
public surface in events Phase 14a (website#196 / docs#224): a public calendar, event pages, arcs
|
||||
and participation history, plus an `events` capability string in the `version` block. This
|
||||
milestone is **events Phase 14b** — the app's half — and the phase was split on the repo line for
|
||||
the reason 12a/12b was: the app cannot be walked against a page that does not exist yet.
|
||||
|
||||
**Design of record: [`../website/EVENTS.md`](../website/EVENTS.md)** §I and its API surface table,
|
||||
and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 14b. The contract is normative
|
||||
there; this entry records what the app does about it.
|
||||
|
||||
**No backend work.** All four routes are live on the website's `edge`:
|
||||
`GET /public/events`, `/public/events/:slug` (with `?run=`), `/public/events/series/:slug`, and
|
||||
`GET /player/events/history`. `MODULE_API_VERSION` is untouched — nothing about the module
|
||||
contract changes here.
|
||||
|
||||
#### The prerequisite, which is in this milestone and not after it
|
||||
|
||||
**The app cannot tell a module that is not installed from a lookup that failed, and it renders a
|
||||
broken drawer because of it.** `ShardFeaturesRepository.refresh()` maps *every* failure to `null`
|
||||
and `canSee(null, …)` answers `true`, so on a site with no `uo` module — which is any site running
|
||||
a different game — `GET /public/shard/features` 404s, all five shard rows render, and every one of
|
||||
them 404s when tapped. Failing open is right *within* a module that is installed (an admin may
|
||||
switch a surface off between two refreshes, and the server gates every call regardless) and wrong
|
||||
for one that is not there at all.
|
||||
|
||||
Three decisions, taken by the org lead on 2026-09-08:
|
||||
|
||||
- **Absence of an answer is not an answer of absence.** A *successful* read of
|
||||
`GET /public/modules` that does not name a capability **hides** the rows that need it — that is
|
||||
an answer. A read that *failed* keeps the last answer this host gave, and when the host has
|
||||
never answered the gate stays open and the drawer renders as it does today. The bug is that the
|
||||
current code collapses those three into one `null`.
|
||||
- **The capability gate composes with the feature gate; it does not replace it.** They answer
|
||||
different questions and both must pass. `capability` asks *is this module installed at all*, and
|
||||
is per-host — it changes when an operator installs or removes a module. `feature` asks *does this
|
||||
shard publish this surface to this viewer*, and is per-viewer — it changes on sign-in. That is
|
||||
also why the two refresh on different clocks: capabilities with the appearance (per host),
|
||||
features with the session (per viewer).
|
||||
- **One capability gates all five shard rows: `shard`.** module-uo also declares `atlas`, `market`,
|
||||
`champs`, `guilds`, `governors`, `houses` and `cliloc`, and gating each row on its own string
|
||||
would be a second, worse copy of what `feature` already decides. The two new events rows gate on
|
||||
core's own `events`.
|
||||
|
||||
Core's list and a module's are **separate lists**, deliberately, and the app reads both:
|
||||
`capabilities` in the `version` block of `GET /public/status` for core, and `GET /public/modules`
|
||||
for the installed modules. See [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.9 — an
|
||||
unknown string is treated as absent, and **no route may be inferred from one**.
|
||||
|
||||
#### The website path → route table was stale, and that is a second live defect
|
||||
|
||||
Found while scoping this milestone, and **fixed here** rather than logged (org lead, 2026-09-08).
|
||||
[`NavPaths.kt`](#) quotes the website's `export const NAV` verbatim as sixteen rows. Since the
|
||||
module-system cutover on 2026-08-12 core's `NAV` is **eight** rows and the nine shard rows belong
|
||||
to module-uo, which registers them at `/uo/shard`, `/uo/champs`, `/uo/guilds`, `/uo/governors`,
|
||||
`/uo/houses`, `/uo/rules`, `/uo/atlas`, `/uo/leaderboards` and `/uo/market` — core mounts a
|
||||
module's public pages at `/<id>/<path>`. Three things follow, all of them true of the shipped app:
|
||||
|
||||
- a nav override an admin sets on any shard row is **ignored**, because the merge intersects with
|
||||
the app's own menu through a table that no longer resolves those paths;
|
||||
- an added link to a shard page **hands off to a Custom Tab** instead of opening natively, and
|
||||
`/uo` alone resolves to a CMS page that does not exist;
|
||||
- `WEB_ROUTE_ORDER` is the number line a stored `order` is compared against, and a sixteen-row
|
||||
line against a nav that now numbers core 0–7 and appends the module's rows after it puts a
|
||||
partly-overridden drawer in the wrong order. `/site/events` is a new core row at index 2, so
|
||||
every row after News shifts.
|
||||
|
||||
**The nine `/uo/*` paths are hardcoded, and documented as coupling to one module** (org lead). The
|
||||
alternative — deriving `/<id>/…` from the module list — infers a route from a capability, which
|
||||
§2.9 forbids, and would hardcode the same path shape less visibly. A site running a different game
|
||||
module maps none of the nine and its links hand off to the browser, which is the correct answer.
|
||||
|
||||
#### What ships
|
||||
|
||||
Four screens, one per 14a surface, and the arc is included because the event page links to it —
|
||||
omitting it would make that link a dead end or a hand-off out of a native screen:
|
||||
|
||||
| Screen | Route | Reads |
|
||||
| --- | --- | --- |
|
||||
| **Events** (the calendar) | `events` | `GET /public/events` |
|
||||
| **Event** | `events/{slug}?run={run}` | `GET /public/events/:slug` |
|
||||
| **Arc** | `events/series/{slug}` | `GET /public/events/series/:slug` |
|
||||
| **My Events** | `account/events` | `GET /player/events/history` |
|
||||
|
||||
- **The calendar is a list, and a projection is drawn differently from a run.** Both are §I's
|
||||
rules, one tier along: an entry past the materialisation horizon is arithmetic with nothing
|
||||
committed to it, so it is labelled rather than drawn as a booking.
|
||||
- **The day heading is the reader's, the time beside it is the event's.** The split is §I's and it
|
||||
is the one thing about event times that is easy to get wrong. A player in Berlin told an American
|
||||
shard's event is at 02:00 has been told something true and useless.
|
||||
- **The status word follows the clock, not the status.** The server publishes `failed` and `missed`
|
||||
as `cancelled`; *did not happen* is right for a past occurrence and false for a future one, and a
|
||||
run four days out that an operator called off is the common case. This is 14a's own defect, and
|
||||
the app must not re-introduce it in Kotlin.
|
||||
- **`?run=` is carried, not dropped.** The page lives at the definition's slug so a weekly event
|
||||
has one address, and the occurrence is in the query.
|
||||
|
||||
**My Events is one drawer row at `SIGNED_IN`, not two** (org lead). The website mounts
|
||||
participation history twice — `/account/events` and `/admin/events/mine` — only because
|
||||
`RequirePlayer` guards `/account` and the route behind it is role-agnostic. The app's drawer has no
|
||||
such wall: `GET /player/events/history` is `requireAuth` alone and self-scoped on the caller's own
|
||||
id, so one row serves players and staff alike. It is deliberately **not** at `PLAYER` access, which
|
||||
would imply event history is game-linked data; it is not, and a shard with no game module still has
|
||||
events.
|
||||
|
||||
#### An inbox link to an event opens the app, not the browser
|
||||
|
||||
**Departing from the recommendation** (org lead, 2026-09-08). Engagement Phase 8's contract is that
|
||||
an inbox item's `url` is site-relative and hands off to a Custom Tab. Phase 14a gave the six public
|
||||
`event.` triggers an `eventUrl` of exactly the form `/site/events/<slug>?run=<id>`, so an inbox row
|
||||
about an event now has a native destination and should use it.
|
||||
|
||||
The mechanism is the one that already exists — `resolveWebPath` — rather than a second one. That
|
||||
forces one rule to change, and it is worth stating because it is the exact trap the rule was
|
||||
written to avoid: **a path carrying a query hands off**, because no app route took one and a
|
||||
native match would silently drop what the admin wrote. The event page is the first app route that
|
||||
takes a query, so the rule becomes *a query hands off, except `run` on an event page*. Anything
|
||||
else in the query string, and any fragment, still hands off untouched.
|
||||
|
||||
- **Excluded**, in the same class as M10's, M11's and M12's exclusions: the events **authoring**
|
||||
surface. The app does not gain an event editor, a run console or the cap meter; Phase 13 built
|
||||
those for the web and they are admin *configuration*, which the app is a consumer of and not an
|
||||
editor for.
|
||||
|
||||
**`account/events`, not `events/mine`**, and the reason is a bug one tier along: `events/mine` and
|
||||
`events/{slug}` are both two segments, and a static-versus-argument race between two NavHost
|
||||
patterns is exactly what events Phase 13 found in React Router, where a static `events/new`
|
||||
outranked `events/:id` and made creating an event impossible for seven phases. Under `account/`
|
||||
there is no dynamic sibling and no race to lose.
|
||||
|
||||
**Verified:** the app suite (**570 tests, 0 failures**), and an emulator walk against a local
|
||||
website running the 14a code, at three rungs — anonymous, player and admin.
|
||||
|
||||
#### The walk found three defects, and 563 green tests found none of them
|
||||
|
||||
The rig's own site had **no `uo` module installed**, which made it the exact case the prerequisite
|
||||
fix is for, and is why two of the three were visible at all.
|
||||
|
||||
- **The three player game-data rows were not gated.** `My characters`, `My vendors` and `My houses`
|
||||
read `/player/shard/*` — the same module's *player* mount — and rendered on a backend with no
|
||||
module, answering *"This content couldn't be found"* with a Retry button. The gate had been
|
||||
applied to the five public shard rows only. The test that was meant to catch this asked whether
|
||||
every row **with a `feature`** declared the capability, which is true and insufficient: these
|
||||
three carry no feature, because they are gated by ownership rather than by the visibility
|
||||
framework. It now asks by **route**.
|
||||
- **`score` is `DECIMAL(18,4)`, and declaring it an integer breaks the whole response.** The first
|
||||
history row the walk read scored `318.5`. kotlinx does not round a fraction into a `Long` — it
|
||||
refuses the entire body, so a `200` reached the screen as *"Something went wrong on the server."*
|
||||
The same field is on the public results table, so **any** event whose module scores fractionally
|
||||
would have broken the event page for every anonymous visitor.
|
||||
- **A drawer route's view model outlives a sign-out, and this one is per-account.**
|
||||
`navigateTopLevel` saves and restores back-stack state, so the entry keeps its `ViewModelStore`
|
||||
and a view model that loads only in `init` never runs again. Signing out of the admin and back in
|
||||
as a player showed the **player the admin's participation history**, with no request made at all.
|
||||
It now keys on the signed-in account id, and the shell sends a signed-out caller home rather than
|
||||
letting the screen ask and render a 401.
|
||||
|
||||
What the walk **confirmed working**: every shard row absent on a module-less backend at all three
|
||||
rungs while Events remained; a cancelled occurrence three days out reading *Cancelled* rather than
|
||||
*Did not happen*, on both the calendar and the event page; projections labelled; the event's own
|
||||
zone beside the reader's own day; an arc; `?run=` surviving the NavHost pattern to reach the server;
|
||||
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.
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||
|
||||
Reference in New Issue
Block a user