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:
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user