docs(website): the public surface, and the flag that decides what reaches it (Phase 14a)

EVENTS.md gains what the phase settled -- a new §I section on the public
surface, the four API surface rows filled in, `listed` in §D, and the §J
rows for player profiles and mobile. EVENTS_PLAN.md records the 14a/14b
split and 14a as built. BACKEND_DESIGN.md points at the two tiers the
Phase 14a reads live on. MODULE_API.md records core's own capability list
beside a module's -- the same word, a separate list, and why.

Two things this phase corrected in the document rather than in code.

"Venue" was never a field. §I's screens table and the API surface table had
both described one since the first revision; there has never been a column,
a spec key, an input on Phase 13's form, or a string anywhere in either
repo. Rather than add a field on the way past to a public page, both
descriptions dropped it.

And the six public triggers' missing url variable, which this document has
carried as a promise since Phase 10, is now kept: `eventUrl`, carrying
`?run=`, arriving with the page it points at.

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 06:18:55 -05:00
parent c17893a215
commit 9aee5920af
4 changed files with 172 additions and 27 deletions

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,83 @@ 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.
> **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 +1452,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 +1767,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 +1851,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 +1957,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 |