Merge pull request 'docs(events): the Event System record — the cutover step 16b missed (edgemain)' (#232) from edge into main

Reviewed-on: #232
This commit is contained in:
2026-09-10 02:54:40 +00:00
12 changed files with 5205 additions and 197 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