From 9aee5920af5957548d2947ac0543cec43eaa94ff Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 06:18:55 -0500 Subject: [PATCH] docs(website): the public surface, and the flag that decides what reaches it (Phase 14a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/BACKEND_DESIGN.md | 15 +++-- website/EVENTS.md | 122 +++++++++++++++++++++++++++++++++----- website/EVENTS_PLAN.md | 51 +++++++++++++--- website/MODULE_API.md | 11 ++++ 4 files changed, 172 insertions(+), 27 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index a05f4a3..b04bf24 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 84a574f..b453a62 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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 | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 6a2bb91..05a375c 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -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,51 @@ 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. + +#### 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. --- diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 290eaa7..8bf08ca 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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