feat(events): the app's events screens, and the module rows that were never gated (Phase 14b) #44

Merged
whitlocktech merged 1 commits from feature/events-p14b-app into edge 2026-09-08 21:59:26 +00:00
Member

Events Phase 14b. One of two: android-app, docs (docs#225), both onto edge. Recorded as M13 in docs/android/PLAN.md, written before the code as the plan requires. MODULE_API_VERSION is untouched — nothing about the module contract changed.

Phase 14 was split on the repo line: the app cannot be walked against a page that does not exist yet, so this waited for 14a (website#196) to merge. Four screens on the four routes it shipped — the public calendar, an event page carrying ?run=, an arc, and participation history.

Six org-lead decisions. One departed from the recommendation: an inbox link to an event opens the app rather than a Custom Tab.

The prerequisite fix is the larger half

The app could not tell a module that is not installed from a lookup that failed. ShardFeaturesRepository mapped every failure to null and canSee(null, …) answers true, so on a site with no uo module /public/shard/features 404s, every shard row rendered, and every one of them 404'd when tapped.

Absence of an answer is not an answer of absence — three states where there was one:

  • a successful /public/modules read that does not name the string hides the rows;
  • a failed read keeps the last answer this host gave, because a moment offline is not an uninstall;
  • a host that has never answered leaves the gate open, so the drawer renders as it did rather than flickering its rows in on every cold start.

Capability and feature are two gates that compose, and they refresh on different clocks because they answer different questions: capability asks is the module installed (per host, resolved beside the appearance, invalidated on a server switch); feature asks does this shard publish this surface to this viewer (per viewer, resolved on session change). Core's capability list and a module's stay separate lists, per MODULE_API §2.9.

A second, older defect, fixed here on the org lead's call

The 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 belong to module-uo at /uo/shard, /uo/champs, … because core mounts a module's pages at /<id>/<path>. So a nav override on any shard row was ignored, an added link to one handed off to a browser instead of opening natively, and WEB_ROUTE_ORDER — the number line a stored order is compared against — was a sixteen-row line against a differently numbered nav.

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 unmapped item never joined it.

The nine /uo/* paths are hardcoded and documented as coupling to one module. Deriving /<id>/… from the module list is what §2.9 forbids and would hide the same coupling.

Through resolveWebPath rather than a second link-routing mechanism — which forces the one rule that function exists to enforce, a path carrying a query hands off, to gain an exception. It is exactly one key on one path: ?run= on an event page. A second parameter, anything else in the query, or any fragment still hands off, so an admin who writes ?utm=mail gets the browser that honours it.

Three defects, and 563 green tests found none of them

The rig's site had no uo module installed, which is what made two of them visible at all.

  • The three player game-data rows were never gated. My characters/vendors/houses read /player/shard/* — the same module's player mount — and rendered on a module-less backend, 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 meant to catch this asked whether every row with a feature declared the capability, which is true and insufficient: those 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 was declared an integer. 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 — 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. 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.

InboxViewModel has the identical shape and was deliberately left alone — it is engagement Phase 8's and outside this phase; worth a decision of its own.

Two decisions worth stating

account/events, not events/mine. events/mine and events/{slug} are both two segments, and a static-versus-argument race between two NavHost patterns is exactly the bug Phase 13 found one tier along, where a static events/new outranked events/:id in React Router and made creating an event impossible for seven phases.

One history row at SIGNED_IN, not two. /player/events/history is requireAuth alone and self-scoped; the website mounts it twice only because RequirePlayer guards /account there. Deliberately not PLAYER — event history is not game-linked data, and a shard with no game module still has events.

Checks

npm-equivalent: 570 tests, 0 failures (563 before, 54 new; the delta is net of the six NavPaths cases added and the stale assertions corrected). The app builds.

Walked on an emulator against a local website running the 14a code, at three rungs — anonymous, player and admin. Every shard row absent on the module-less backend 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 natively while a forum link still opened Chrome's CustomTabActivity (checked in dumpsys); and participation history self-scoped, proved with two accounts rather than asserted.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Events Phase 14b. One of **two**: `android-app`, `docs` (docs#225), both onto `edge`. Recorded as **M13** in [`docs/android/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/225), written before the code as the plan requires. **`MODULE_API_VERSION` is untouched** — nothing about the module contract changed. Phase 14 was split on the repo line: the app cannot be walked against a page that does not exist yet, so this waited for 14a (website#196) to merge. Four screens on the four routes it shipped — the public calendar, an event page carrying `?run=`, an arc, and participation history. Six org-lead decisions. **One departed from the recommendation:** an inbox link to an event opens the app rather than a Custom Tab. ## The prerequisite fix is the larger half The app could not tell a module that is **not installed** from a lookup that **failed**. `ShardFeaturesRepository` mapped every failure to `null` and `canSee(null, …)` answers `true`, so on a site with no `uo` module `/public/shard/features` 404s, every shard row rendered, and every one of them 404'd when tapped. **Absence of an answer is not an answer of absence** — three states where there was one: - a **successful** `/public/modules` read that does not name the string **hides** the rows; - a **failed** read keeps the last answer this host gave, because a moment offline is not an uninstall; - a host that has **never** answered leaves the gate open, so the drawer renders as it did rather than flickering its rows in on every cold start. **Capability and feature are two gates that compose**, and they refresh on different clocks because they answer different questions: capability asks *is the module installed* (per host, resolved beside the appearance, invalidated on a server switch); feature asks *does this shard publish this surface to this viewer* (per viewer, resolved on session change). Core's capability list and a module's stay **separate lists**, per MODULE_API §2.9. ## A second, older defect, fixed here on the org lead's call The 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 belong to module-uo at `/uo/shard`, `/uo/champs`, … because core mounts a module's pages at `/<id>/<path>`. So a nav override on any shard row was ignored, an added link to one handed off to a browser instead of opening natively, and `WEB_ROUTE_ORDER` — the number line a stored `order` is compared against — was a sixteen-row line against a differently numbered nav. **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 unmapped item never joined it. The nine `/uo/*` paths are hardcoded and documented as coupling to one module. Deriving `/<id>/…` from the module list is what §2.9 forbids and would hide the same coupling. ## An inbox link to an event opens the app Through `resolveWebPath` rather than a second link-routing mechanism — which forces the one rule that function exists to enforce, *a path carrying a query hands off*, to gain an exception. It is exactly one key on one path: `?run=` on an event page. A second parameter, anything else in the query, or any fragment still hands off, so an admin who writes `?utm=mail` gets the browser that honours it. ## Three defects, and 563 green tests found none of them The rig's site had **no `uo` module installed**, which is what made two of them visible at all. - **The three player game-data rows were never gated.** `My characters/vendors/houses` read `/player/shard/*` — the same module's *player* mount — and rendered on a module-less backend, 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 meant to catch this asked whether every row **with a `feature`** declared the capability, which is true and insufficient: those 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 was declared an integer.** 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 — **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.** `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. **`InboxViewModel` has the identical shape and was deliberately left alone** — it is engagement Phase 8's and outside this phase; worth a decision of its own. ## Two decisions worth stating **`account/events`, not `events/mine`.** `events/mine` and `events/{slug}` are both two segments, and a static-versus-argument race between two NavHost patterns is exactly the bug Phase 13 found one tier along, where a static `events/new` outranked `events/:id` in React Router and made creating an event impossible for seven phases. **One history row at `SIGNED_IN`, not two.** `/player/events/history` is `requireAuth` alone and self-scoped; the website mounts it twice only because `RequirePlayer` guards `/account` there. Deliberately not `PLAYER` — event history is not game-linked data, and a shard with no game module still has events. ## Checks `npm`-equivalent: **570 tests, 0 failures** (563 before, 54 new; the delta is net of the six NavPaths cases added and the stale assertions corrected). The app builds. **Walked on an emulator against a local website running the 14a code, at three rungs** — anonymous, player and admin. Every shard row absent on the module-less backend 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 natively while a forum link still opened Chrome's `CustomTabActivity` (checked in `dumpsys`); and participation history self-scoped, **proved with two accounts** rather than asserted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-08 18:12:27 +00:00
feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)
All checks were successful
PR Checks / android-build (pull_request) Successful in 12m7s
e10e1f1617
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
whitlocktech merged commit f3d90b189d into edge 2026-09-08 21:59:26 +00:00
whitlocktech deleted branch feature/events-p14b-app 2026-09-08 21:59:28 +00:00
Sign in to join this conversation.
No description provided.