docs(events): Phase 1 as built — schema, CRUD and the core action registry

The docs half of RunicGateway/website#<n>. Three files.

**BACKEND_DESIGN.md** gains the six event tables, column by column, and the
eleven admin routes. Written where the other table groups are, in the same
shape, because the argument for a column belongs beside the column.

**EVENTS.md** records four things the build settled that §D and §F had left
open:

  - `event_definitions.spec`, the working copy. §D's column list does not name
    one because §D describes what a PUBLISHED event is made of — but "editing a
    draft is free; no version exists yet" means the draft has to live somewhere,
    and it cannot be an `event_versions` row: that table is immutable and a run
    pins one, so a mutable unpublished row in it would be exactly what
    versioning exists to prevent.
  - A param's `example` is REQUIRED, on optional params too, the same rule
    `registerEventTriggers` makes of a variable's example and for the same
    reason: it is the authoring form's placeholder, one word at declaration
    time and unreconstructable afterwards.
  - The authoring side of dormancy. §F said what happens at DISPATCH; the save
    path draws the same line one step earlier, in the shape `engagement_rules`
    established — a saved step may keep an unregistered action, a new step may
    not add one, and a dormant step blocks the publish rather than the save.
  - Publish re-validates against the registries as they stand at that moment,
    not from the save that wrote the spec.

Plus two routes the § API surface table did not name — `GET /admin/events/:id`
(the list serves a summary; the editor needs the tree) and `GET
/admin/events/series` (a form cannot offer a value it cannot enumerate) — and a
note stating which of that table's rows Phase 1 deliberately did not build.

**EVENTS_PLAN.md** marks Phase 1 complete, names those four settlements, and
states the two deliberate absences so a reviewer does not read them as gaps:
the live run controls are not stubbed, and core's three `perform()` bodies
answer `{ ok: false }` rather than `{ ok: true }` — `ok: true` on an action
that did nothing is a recorded world change that did not occur.

`api-route-inventory.json` is NOT resynced here. It has been stale since
engagement Phase 2 and is 47 routes behind; catching it up in this PR would
bury a 13-route change under an unrelated 47.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 23:28:26 -05:00
parent 83309e9d09
commit 9ba119dc5a
3 changed files with 252 additions and 5 deletions

View File

@@ -400,8 +400,8 @@ tables carry no module prefix.
| 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`, `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_series` | id, name, description, ordering. Definitions optionally belong to one. | **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_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_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`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. |
| `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. |
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
@@ -411,6 +411,14 @@ tables carry no module prefix.
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
| `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. |
> **`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
> one, so a mutable unpublished row in it would be precisely what versioning exists to prevent.
> Publishing copies the column into a version and leaves it standing as the next draft. `series_order`
> is the same kind of addition — `event_series.ordering` places a series among the others, and a
> definition's place *within* its arc is the column an editor drags.
### Not tables, deliberately
- **Phases** — configuration in `event_versions.spec`, materialised as steps when a run starts. A
@@ -592,7 +600,11 @@ api.registerEventLeases([{
game words a chess ladder has no use for.
- **Params are validated at save *and* at dispatch, against the declared version.** A step stores the
`action_version` it was authored against; a bump makes it render a warning in the editor rather
than dispatch a mistyped parameter.
than dispatch a mistyped parameter. A param's `example` is **required**, on the optional params as
well as the required ones — the same rule `registerEventTriggers` makes of a variable's example and
for the same reason. It is the authoring form's placeholder, it is one word at declaration time,
and it is unreconstructable afterwards; a blank box is how an unattended world write comes to be
scheduled with a typo in it.
- **Resources are named by the module and owned by core.** `kind` and `ref` are opaque strings core
stores verbatim — `ctx.teams.activity.push`'s exact treatment. Core does the remembering; the
module does the meaning.
@@ -610,7 +622,11 @@ api.registerEventLeases([{
**no concept of "the game being up"** — only `{ ok: false, retry: true }` — because a module with
six sidecars cannot answer that question in the singular.
- **An action whose module is uninstalled goes dormant, never an error.** A step naming it fails
`terminal` with the module named and the run degrades — never a silent skip.
`terminal` with the module named and the run degrades — never a silent skip. The authoring side
draws the same line one step earlier, in the shape `engagement_rules` established for a dormant
trigger: **a step already in a saved spec may keep an unregistered action and a new step may not
add one**, so an uninstall is never destructive after the fact — and a dormant step blocks the
*publish*, because a version is what a run pins and a run cannot dispatch a verb nobody registers.
- **Actions and budgets are their own id spaces.** An action names a verb, a trigger names an event,
a budget names a resource dimension.
@@ -924,7 +940,7 @@ without stealing an edit.
| Edit | Effect |
| --- | --- |
| Editing a `draft` | Free. No version exists yet. |
| Publishing | Snapshots the whole spec into an immutable `event_versions` row and points `current_version_id` at it. |
| Publishing | Snapshots the whole spec into an immutable `event_versions` row and points `current_version_id` at it. The spec is **re-validated against the registries as they stand at that moment**, not trusted from the save that wrote it: a module uninstalled in between must block the publish rather than produce a run that fails at dispatch with the world half-changed. |
| Editing a `ready` definition with no live run | Creates the next version on publish. Future runs use it. |
| Editing while a run is live | Creates the next version. **The live run keeps the version it pinned** and is unaffected. The editor says so. |
| Changing what a *running* event does | **Not an edit.** The live controls are pause, resume, skip, force-advance and cancel — each logged, each attributable, none mutating a version. Anything more expressive is a cancel and a new run, because a half-executed spec edited mid-flight is neither reproducible nor auditable. |
@@ -940,6 +956,7 @@ no URL moved.
| Route | Gate | |
| --- | --- | --- |
| `GET /admin/events` | staff | definitions, state, next occurrence, health |
| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads |
| `POST /admin/events` | admin, editor | create a draft |
| `PUT /admin/events/:id` | admin, editor | edit the draft spec |
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
@@ -955,6 +972,7 @@ no URL moved.
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions |
| `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param |
| `GET /admin/events/series` | staff | the arcs a definition may belong to |
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps |
| `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 |
@@ -966,6 +984,21 @@ no URL moved.
> dispatches nothing, and the author who wrote the definition is exactly who should be able to price
> it against the caps before asking an admin to publish it.
> **Two rows above were added by Phase 1 rather than decided in §N**, and both are derived from
> what the surface needs rather than from a new policy. `GET /admin/events/:id` exists because the
> list route serves a summary and the editor needs the whole authored tree; `GET
> /admin/events/series` exists because a definition carries `series_id` and a form cannot offer a
> value it cannot enumerate. Both are staff reads of data the list route already exposes, so neither
> widens the surface's reach.
**What Phase 1 built, and what it deliberately did not.** Definitions CRUD, publish, archive, the
version history, the action catalog, the series read and the run reads are live. Every route that
acts on a run *in flight* — pause, resume, advance, cancel, step skip/retry/confirm, cleanup — is
absent rather than stubbed, because nothing is in flight until the runner exists: a control that
answers `200` and does nothing is worse than one that is not there. `verify` and `GET/PUT
/admin/events/actions` are absent for the same kind of reason — there are no caps to price against
and no switchboard to serve until the phase that builds them.
A module registers actions server-side and adds **no routes** for them beyond its option endpoints,
which is what keeps the browser from being able to name a transport.