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:
2026-09-08 13:09:02 -05:00
parent b6fefc5466
commit 0a61915e50
3 changed files with 221 additions and 12 deletions

View File

@@ -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 (M6M8) follow the designed app.
rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was
discarding three 98100%-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 07 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

View File

@@ -1475,7 +1475,7 @@ with no `roles` — every account has a participation history and the server sco
| **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication |
| **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module |
| **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out |
| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half is Phase 14b**, with its prerequisite: the app hardcodes `api/v1/public/shard/stream` — a module path — reads `/public/modules` nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry renders and every one of them 404s. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates the group on a capability string rather than on a module id. | server built, app is 14b |
| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half was built in Phase 14b** (`android-app` M13), with its prerequisite: the app read `/public/modules` nowhere, and `ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry rendered and every one of them 404d. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates on a capability string rather than on a module id, and its governing rule is that **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, and a host that never answered leaves the gate open. Core's `events` gates the two event rows. The app's four screens are the calendar, an event page (carrying `?run=`), an arc and participation history — the last as **one** row for any signed-in account, because the route is `requireAuth` alone and self-scoped and only the web's own `/account` guard needed a second mount. | built |
| **Admin audit** | `ctx.activity.log` for every edit, publish and manual transition. The runtime record is `event_run_log`, because `activity_log.detail` is `TEXT` and cannot be queried by phase or step. | exists |
---

View File

@@ -1736,21 +1736,51 @@ a clock — and the third was a screen the reviewing admin could not open at all
`RequirePlayer` guards `/account` and the route behind it is role-agnostic. Engagement Phase 7 had
already solved that one; the fix is its `notificationPaths.js` mapping gaining a third entry.
#### Phase 14b — the app (`android-app` + `docs`)
#### Phase 14b — the app (`android-app` + `docs`) — **built**
The app's events screens, and **one prerequisite fix that is in this phase and not after it.** The
Android app hardcodes `api/v1/public/shard/stream` — a *module* path — reads `/public/modules`
nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown",
which `canSee` treats as visible. On a site with no `uo` module installed that route 404s, so every
shard entry in the drawer renders and every one of them 404s. Failing open is right *within* a module
that exists and wrong for one that is not installed; the fix gates the group on a **capability
string** — `shard`, which module-uo declares — rather than on a module id, and gates the new events
entry on core's `events`.
`android-app#44`, `docs#225`. **TWO repos**, and `MODULE_API_VERSION` is untouched. Recorded as
**M13** in `docs/android/PLAN.md`, which gained the milestone before the code was written.
`docs/android/PLAN.md` gains the milestone before the code is written.
The app's events screens — calendar, event page, arc, participation history — and **the prerequisite
fix, which is in this phase and not after it.** The app read `/public/modules` nowhere, and
`ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee`
treats as visible. On a site with no `uo` module that route 404s, so every shard entry rendered and
every one of them 404d. Failing open is right *within* a module that exists and wrong for one that is
not installed.
Six org-lead decisions. Four up front — the failure direction, the screen scope, where history lives,
and native deep links; and two on the scope of the second defect below. **One departed from the
recommendation:** an inbox link to `/site/events/<slug>?run=<id>` **opens the app** rather than a
Custom Tab, reusing `resolveWebPath` rather than adding a second link-routing mechanism. That forces
the one rule that function was written to enforce — *a query hands off* — to gain an exception, and it
is exactly one key on exactly one path.
**Absence of an answer is not an answer of absence**, which is the distinction the whole fix rests on:
a *successful* `/public/modules` read that does not name `shard` **hides** the rows; a *failed* read
keeps the last answer the host gave; a host that has never answered leaves the gate open. The old code
collapsed all three into one `null`. Capability and feature are a second and third gate that compose:
capability asks *is the module installed* (per host), feature asks *does this shard publish it to this
viewer* (per viewer).
**A second, older defect was found while scoping and fixed here** (org lead): the app's website
path → route table had been wrong since the module-system cutover on 2026-08-12. It quoted a
sixteen-row `NAV`; core's is now **eight** and the nine shard rows moved to `/uo/*`, because core
mounts a module's pages at `/<id>/<path>`. Three live consequences: a nav override on any shard row
was ignored, an added link to a shard page handed off to a browser instead of opening natively, and
the sort-key number line was wrong. Two tests in the existing suite had been passing **vacuously**
since that day — they asserted a section came out empty, and it did, because the item never joined it.
**Ships:** the calendar, event pages and participation history in the app.
**Verify:** the app suite; an emulator walk.
**Verify:** the app suite (**570 tests, 0 failures**); an emulator walk at three rungs.
**The walk found three defects and the suite found none of them.** The rig's own site had no `uo`
module, which is what made two of them visible. The three player game-data rows read `/player/shard/*`
and were not gated, so they rendered and 404d — and the test meant to catch that asked whether every
row *with a `feature`* declared the capability, which those three do not have. `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. And 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. See `docs/android/PLAN.md` M13.
---