docs(events): the Event System record — the cutover step 16b missed (edge → main) #232

Merged
whitlocktech merged 46 commits from edge into main 2026-09-09 21:55:11 -05:00
4 changed files with 197 additions and 27 deletions
Showing only changes of commit b6fefc5466 - Show all commits

View File

@@ -1712,10 +1712,17 @@ file a route sits in — that is the property the route manifest freezes.
> **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it.
> The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5),
> the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup`
> (Phase 8) and the cap meter (Phase 13) are not listed here. **[`EVENTS.md`](EVENTS.md) § API
> surface is the canonical table** and carries every one of them with the reasoning for its gate;
> re-listing them here would be a second copy of a contract that file owns, and the copy that
> drifts is always the second one.
> (Phase 8), the cap meter (Phase 13) and the public/player reads (Phase 14a) are not listed here.
> **[`EVENTS.md`](EVENTS.md) § API surface is the canonical table** and carries every one of them
> with the reasoning for its gate; re-listing them here would be a second copy of a contract that
> file owns, and the copy that drifts is always the second one.
>
> The Phase 14a reads are the only ones outside this tier: `GET /public/events`,
> `/public/events/:slug` and `/public/events/series/:slug` on the anonymous surface, and
> `GET /player/events/history` on the self-service one. What makes something visible there is
> `listed` **and** `ready` **and** not a rehearsal, and all three are predicates in SQL rather than
> checks a caller performs — a draft, an archived definition and an unlisted one all answer 404,
> indistinguishable from a slug that never existed.
Every admin write logs to `activity_log`.

View File

@@ -419,7 +419,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
| Table | Holds | Why a table |
| --- | --- | --- |
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. |
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, `listed` (Phase 14a), created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. **`listed` is announcement, not permission** — see below. |
| `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. |
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`, and (Phase 6) `verified_at` / `verified_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. The two `verified_*` columns are the exception that proves the immutability: they describe something that happened **to** the version afterwards — a review — rather than changing the plan reviewed, which is also why a pass recorded against one stays true for ever ([§K](#k--security-model)). |
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
@@ -431,6 +431,15 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
> **`listed` is Phase 14a's one addition to this table**, and it is deliberately not a second answer
> to what `state` answers. `state` says whether an event is SCHEDULABLE; `listed` says whether it is
> ANNOUNCED. The two came apart the moment there was a public surface at all, because publishing is
> what makes a definition runnable — so without the column a surprise invasion would have to be
> advertised a fortnight in advance in order to be allowed to happen. It defaults to 1, so every
> definition keeps the behaviour it had while the only reader was staff, and unlisting is the
> deliberate act. It hides the definition, its runs and its projections from the public surfaces and
> from a participant's own history; it hides nothing from staff.
>
> **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it
> follows from "editing a draft is free; no version exists yet" below. A draft's working spec has to
> live somewhere, and it cannot be an `event_versions` row: that table is immutable and a run pins
@@ -1189,8 +1198,8 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th
| Screen | Pattern it reuses | What is new |
| --- | --- | --- |
| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. |
| **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. **Finished in Phase 13**, which added the per-phase cap draw, the advance-condition builder and the start dialog — `rehearsal`, `scope` and `params` have been on the start route since Phase 10 and the screen posted none of them. An event whose concurrency key names a `{placeholder}` therefore could not be started correctly by hand at all: the key is rendered from the run's own params, so every manual run rendered the same one and the second was refused as an overlap with the first. |
| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. **The public half is Phase 14a's and is a LIST, not a grid** — the same data answering a different question; see below. |
| **Definition editor** — basics, storyline, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. **Finished in Phase 13**, which added the per-phase cap draw, the advance-condition builder and the start dialog — `rehearsal`, `scope` and `params` have been on the start route since Phase 10 and the screen posted none of them. An event whose concurrency key names a `{placeholder}` therefore could not be started correctly by hand at all: the key is rendered from the run's own params, so every manual run rendered the same one and the second was refused as an overlap with the first. |
| **Step editor** — pick an action, fill its params | **The condition builder, exactly**: core serves a catalog, the module declared the schema, core renders a form it does not understand | Option sources; a live cap meter. **Built in Phase 13**, with the JSON box kept as a per-step escape hatch — see below. |
| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. **Built in Phase 6**, `admin` only. Each row says whether it is showing a stored decision or a risk-class default, because "an admin turned this on" and "this has always been on" are different facts and only one is a decision somebody made. A cap may only name a dimension the action actually spends — a cap on one it never names would be a number an operator believes is protecting them while it bounds nothing. |
| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. **Built through Phase 6**, caps included: a meter per dimension rather than a sentence, because unlike a gate a cap is two numbers and a name and needs no grammar rendered to be read. Shown on finished runs too — *"how much did last night's invasion actually spawn"* is the same question asked the morning after. |
@@ -1354,10 +1363,102 @@ the morning. Only the variables the condition **names** are recorded on the gate
the payload — because that row is read back onto an admin screen and a copy of a whole game event's
data would be a second copy of exactly the content `engagement_sends` is careful not to keep.
**Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and
published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is
having trouble" is operator information, and the existing shard pages already model exactly this by
rendering last-known state rather than an error.
### What Phase 14a settled about the public surface
An upcoming-events calendar with series and arcs, a live-status page, and published results —
**built**, at `/site/events`, `/site/events/:slug` and `/site/events/series/:slug`, over the three
`/public/events` routes and `GET /player/events/history`.
**A visitor's calendar is a list; an operator's is a grid.** That is not a styling choice made twice.
An operator's question is *"what does this month look like"* — coverage, clashes, the gap on the third
weekend — which a grid answers and a list answers by making them count. A visitor's question is
*"what is on, and when is the next one"*, which a chronological list answers at a glance. The
projection/run distinction survives into the public list unchanged, dashed and labelled *expected*,
for the visitor's version of the operator's reason: past the horizon nothing has been committed to,
and a page drawing a forecast identically to a booking would be promising on the server's behalf.
**What is public is `listed` AND `ready` AND not a rehearsal, and all three are in SQL.** `listed` is
a new column on `event_definitions`, defaulting to 1, and it exists because publishing is what makes
a definition RUNNABLE: without it, a surprise invasion would have to be advertised a fortnight in
advance in order to be allowed to happen. It is **not** a second answer to the question `state`
answers — `state` says schedulable, `listed` says announced — and an unlisted event still schedules,
still runs, and is still on the admin calendar. Filtering in JavaScript after the read would work
exactly as well, right up until the first caller that forgot; a draft, an archived definition and an
unlisted one all answer 404, indistinguishable from a slug that never existed.
**The public shapes are a projection, and the projection is the security boundary.** Nothing is
spread; a field reaches a public entry because a line in `eventPublic.model.js` put it there. The day
somebody adds a column to `event_runs` — a claim token, an operator's note, a last error — a
`{ ...run }` would publish it in the release after the one anybody reviewed. Three things are
deliberately absent from every public shape:
- **The spec.** Phases, steps, actions and their params are the plan for changing a live world. A
visitor is told what is happening and when, and the **label** of the phase while it is happening —
resolved from the version the run PINNED, so an edit since does not relabel a run in flight. A
phase the pinned spec does not name renders nothing rather than a slug.
- **Health, cleanup, claims and errors.** A run with `health: 'degraded'` renders as running, which
is the tolerance this section already required — "the shard is having trouble" is operator
information, and the shard pages already model exactly this by rendering last-known state.
- **`member_key`.** It is the game's own identifier for a character and it is module-opaque, so core
cannot say what publishing one would disclose. A results row shows whatever display name the module
put in `meta`, or nothing.
**`failed` and `missed` are both published as `cancelled`, and `paused` as `live`.** To a visitor the
first two are one event — it was on the calendar and it did not happen — while the difference between
them is entirely about the deployment: `failed` names broken machinery and `missed` names a process
that was down when the schedule came round. Publishing either word would tell a stranger something
true about the server and nothing about the event. `paused` is the same argument in the other
direction: an operator holding a run for two minutes is not a state a public page should render, and
one that said "paused" would invite a question whose answer is internal.
**The six public triggers gained `eventUrl` here, which is the version bump this file promised.**
Until Phase 14a there was no page, so they declared no url at all — `news.post` had already paid for
that mistake once, previewing a link in the template editor that was dead in every mail it sent. The
variable carries `?run=`, and the query string is the whole reason it is a run url rather than an
event url: the page lives at the definition's slug, so a weekly event has one stable address that
survives a retitle, but every one of those triggers is about one OCCURRENCE. It is **optional**, and
`email.button` drops itself when its url interpolates to nothing — so an unlisted event still mails
correctly, with the block absent rather than rendering an inert grey label. `event.run.failed` gains
nothing: an admin reading that the machinery broke wants the console, not the storyline.
**Core publishes an `events` capability string**, in the `version` block that `GET /public/version`
returns and `GET /public/status` embeds. It is the same idea as a module's `capabilities` on
`/public/modules` and deliberately the same word — but a separate list, because core is not a module
and a client must be able to tell *"this backend has events"* from *"a module called core is
installed"*. **The value is in what is absent**: a backend released before Events omits the key
entirely, which is a distinction probing `/public/events` cannot make, where "not built" and
"temporarily down" look alike.
**A series with no listed events is a 404 rather than an empty page.** An arc is a label on its
definitions and nothing else, so a page for an empty one would publish the single fact that an
operator has named something they have not announced.
**Participation history is mounted twice, and it is the third screen to need that.**
`GET /player/events/history` is behind `requireAuth` alone and self-scoped on `req.user.id` — staff
are a superset of players, so a staff member has an attendance record like anyone else. The WEB is
what disagrees: `RequirePlayer` sends anyone who is not a `player` out of `/account`, so a single
mount there is a screen the reviewing admin can never open. The notifications pair hit this exact
wall in engagement Phase 7 and answered it with two routes, one pair of components and one mapping
in `lib/notificationPaths.js`; `eventHistoryPath` joins `inboxPath` and `notificationSettingsPath`
there rather than starting a second file with the same comment at the top of it. The staff path is
`/admin/events/mine`, in the Events section of the sidebar, and it is the one row in that group
with no `roles` — every account has a participation history and the server scopes it to the caller.
> **Three defects, and the live walk is the only thing that found any of them.** Both suites were
> green before it and after each fix. Two were the same mistake in different files — a split reading
> a STATUS where it should read a clock: the calendar told a visitor an event four days away *did not
> happen* (it had been cancelled, and the tense of that word follows the clock, not the status), and
> the event page filed that same future occurrence under *previously*. The third was the mount above.
> A cancelled occurrence still belongs under what is coming, because *"next Friday is off"* is
> exactly what somebody checking a calendar came to find out.
> **A venue was never a field, and Phase 14a stopped saying it was.** §I's screens table and the API
> surface table had both described a venue since the first revision — the definition editor's
> sections, and what an event page shows. There has never been one: not a column, not a key in
> `spec`, not an input on P13's form, not a string anywhere in either repo. Rather than add a field
> on the way past to a public page, both descriptions dropped it; the storyline already carries the
> where. (The word still appears in §G and §B2 as ordinary English for a place in the world, which is
> what it always meant there.)
---
@@ -1370,11 +1471,11 @@ rendering last-known state rather than an error.
| **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. **Built in Phase 10**, on a nullable `announce_jobs.run_id`: a post may now have more than one job, and everything that means "the post's job" — the admin panel, its retry button, `announced_at` — still means the news one. | built |
| **Notifications** | Falls out of Engagement — email, in-app inbox, content-free push tickles, per preference, with suppression and the verification gate. Nothing new. | exists |
| **Teams** | "This Team's members" is already a registered audience, so a guild-scoped event is a rule's audience segment and needs no event-side feature. | free |
| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; the surface that reads it is Phase 14's. | table built |
| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; **the surface that reads it was built in Phase 14a** — `GET /player/events/history` and `/account/events`, self-scoped, with the calendar's own two exclusions so attending an unannounced event does not disclose that it exists. The `member_key` is not published even there: the caller is a player reading their own page, and the key would add a module-opaque string nothing on it can render. | built |
| **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`, an `events` capability string. **Prerequisite:** the app hardcodes `api/v1/public/shard/stream` — a module path — and reads `/public/modules` nowhere, so it cannot render a site whose module it does not know. | app fix first |
| **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 |
| **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 |
---
@@ -1685,10 +1786,10 @@ no URL moved.
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps (Phase 6). `admin` on the read as well as the write; the PUT takes one action at a time |
| `GET /public/events` | — | the calendar: upcoming and live, by category, scope and series |
| `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results |
| `GET /public/events/series/:slug` | — | the arc |
| `GET /player/events/history` | auth | this account's participation |
| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. Defaults to now through 31 days out and the window may span at most 92 — the anonymous surface is the one with no login in front of it |
| `GET /public/events/:slug` | — | **one event** (Phase 14a): storyline, arc, what is live, what is next, what happened recently, and a results table once one is published. Takes an optional `?run=`, which is what an announcement's link carries, so a mail about last Friday's occurrence does not open next Friday's; a run belonging to some other event is **ignored rather than refused**, because a stale link in a months-old mail should land on the event it was about. A draft, an archived definition and an unlisted one all answer 404 |
| `GET /public/events/series/:slug` | — | **the arc** (Phase 14a). A series with no listed events is a 404, not an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced |
| `GET /player/events/history` | auth | **this account's participation** (Phase 14a) — the run, when it was, the score a module reported, and the rank once results were published (null until then, which is a real state rather than an error). Self-scoped on the session with **no id parameter**, deliberately: a route that took one would be a middleware mistake away from publishing who attended what. Keyset-paged on the participation row's id. It obeys the calendar's two exclusions, so attending an unannounced event does not disclose that it exists |
> **Publish and start are `admin`; cancel is not** — that asymmetry is deliberate and decided in
> [§N2](#n--decisions). `verify` sits with `admin, editor` rather than with `start`: a dry run
@@ -1769,6 +1870,17 @@ a sidecar and a game tick — and a pass against a published version is RECORDED
§K's unattended-start gate reads. Neither belongs behind a keystroke. `POST /admin/events/price`
does neither, which is the whole of its design.
**Phase 14a added four routes and no admin route at all**, which is the shape a public phase has: the
three `/public/events` reads and `GET /player/events/history`. Nothing moved. The one change to an
existing answer is a field — `listed` on a definition, in both directions on the admin routes, because
the switch that decides whether an event is announced is edited where the event is authored.
**They are a projection rather than a filtered admin shape, and that is the security property.** Every
other reader of these tables is staff; these are read by nobody at all, so nothing is spread and a
field is published because a line put it there. Building the public answer by DELETING fields from an
operator's is the direction that fails silently — a column added to `event_runs` next year would ride
out through a spread the day it was added.
> **The whole-plan cost check is the one thing no other path makes.** Every per-step check the dry run
> performs is also made at save or at dispatch; the TOTAL is not. Three steps each spawning 15 under a
> cap of 30 pass every individual check and breach the cap on the third — at two in the morning,
@@ -1864,7 +1976,8 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
| **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` |
| **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` |
| **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` |
| **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` |
| **P14a** ✓ | Public calendar, event pages, arcs, player history, the `events` capability | `website` `docs` |
| **P14b** | The app's events screens — and the module-path fix, which is its prerequisite | `android-app` `docs` |
| **P15** | Integration Kit chapter 5 — cannot merge before the cutover exists | `integration-kit` |
| **P16** | Acceptance walk against released artefacts, then `edge` → `main` | all |

View File

@@ -1165,6 +1165,7 @@ push; the ceiling on `run.failed` proved to exclude a moderator.
> the template editor previewed a link that was dead in every mail it sent. Phase 14 adds the
> variable alongside the page it points at, which is a version bump. `event.run.failed` is the
> exception because `/admin/events/runs/:runId` exists today.
> **Done in 14a**, as `eventUrl`, carrying `?run=` — and the six went to version 2.
> - **`startsAtLabel` is a presentational fragment computed at the emitter**, which is
> `ENGAGEMENT.md` §4.6.1 convention 1 rather than a shortcut. `startsAt` is a `datetime` and the
> seam normalises it to an ISO string — right as data, unreadable in a sentence — and a template has
@@ -1699,19 +1700,57 @@ at any point.
---
### Phase 14 — Public, player and mobile (`website` + `android-app` + `docs`)
### Phase 14 — Public, player and mobile — SPLIT into 14a and 14b
Split by the org lead on 2026-09-07, on the repo line: the app cannot be walked against a page that
does not exist yet, so 14b's emulator walk is only meaningful once 14a has merged. The same argument
12a/12b was split on.
#### Phase 14a — the public and player surface (`website` + `docs`) — **built**
`GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`,
`/player/events/history`, and an `events` capability string.
`/player/events/history`, and an `events` capability string. `website#196`, `docs#224`.
**One prerequisite fix, in this phase and not after it.** The Android app hardcodes
`api/v1/public/shard/stream` — a *module* path — and reads `/public/modules` nowhere, so it cannot
render a site whose module it has never heard of. That is a small fix and it blocks the app seeing
events at all.
Four org-lead decisions, all taken up front: split the phase; add a **`listed`** flag rather than
letting `state` mean both schedulable and announced; put the capability string in the **`version`
block** rather than publishing core as a pseudo-module; and **drop "venue"** from this document
rather than adding the field §I had described since the first revision and nothing had ever built.
**Ships:** the public calendar, event pages with their storyline and results, participation history,
and the app.
**Verify:** the client and app suites; an emulator walk.
`listed` is the one that changes the shape of the work. Publishing is what makes a definition
runnable, so without a separate flag a surprise event would have to be advertised in order to be
allowed to happen. It is a column, a switch in P13's editor, and three SQL predicates — never a
filter applied after a read, which works exactly as well until the first caller that forgets.
The six public `event.` triggers gained **`eventUrl`** here, which is the version bump this plan
promised (1 → 2). It carries `?run=`, because the page lives at the definition's slug and every one
of those triggers is about one occurrence. `notify.event-started` gained the button to go with it,
at `seedVersion` 2.
**Ships:** the public calendar, event pages with their storyline and results, and participation
history.
**Verify:** both suites; a browser walk of the calendar, an event page, an arc and the history.
**The walk found three defects and the suites found none of them**, which is the argument for the
walk in one line. Two were one mistake in two files — a split reading a status where it should read
a clock — and the third was a screen the reviewing admin could not open at all, because
`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`)
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`.
`docs/android/PLAN.md` gains the milestone before the code is written.
**Ships:** the calendar, event pages and participation history in the app.
**Verify:** the app suite; an emulator walk.
---

View File

@@ -1422,6 +1422,17 @@ Four fields, in the loader's scan order (§4.2). What is *not* there is the desi
string. A client must treat an unknown capability as absent and must not infer a route from one — the
mount prefixes are `module.json`'s business (§2.3), not the capability list's.
**Core publishes a capability list of its own, and it is deliberately not this one.** Since events
Phase 14a, `GET /public/version` — and `GET /public/status`, which embeds the same block — carries a
`capabilities` array naming what CORE serves beyond the baseline every backend has. It is the same
idea and the same word so that a client feature-detects one way rather than two, and a **separate
list** because core is not a module: publishing core here as a pseudo-module would leave a client
unable to tell *"this backend has events"* from *"a module called core happens to be installed"*,
which is exactly the distinction this endpoint exists to make. The value in core's list is in what is
absent — a backend released before a capability existed omits the key entirely, which is how a client
tells an older site from one that simply has nothing to show. The same rule applies to both: an
unknown string is absent, and no route may be inferred from one.
The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own
rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads
the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route