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:
@@ -1117,6 +1117,177 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### The six event tables — the engine's, game-agnostic (events phase 1)
|
||||||
|
|
||||||
|
Design of record: [`EVENTS.md`](EVENTS.md) §D. Nine core tables are specified there; **six land in
|
||||||
|
Phase 1** — the ones that do not depend on the module contract. `event_action_settings`,
|
||||||
|
`event_run_budget`, `event_run_resources` and `event_run_participants` arrive with the phases that
|
||||||
|
give them a writer, rather than as empty tables nothing reads.
|
||||||
|
|
||||||
|
Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id,
|
||||||
|
a `scope`, a resource kind and a budget dimension are opaque strings core stores and never
|
||||||
|
interprets.
|
||||||
|
|
||||||
|
#### event_series — the arc
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | |
|
||||||
|
| description | TEXT NULL | |
|
||||||
|
| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags |
|
||||||
|
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one
|
||||||
|
arrives with the calendar.
|
||||||
|
|
||||||
|
#### event_definitions — the thing that is listed, scheduled and audited
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| title | VARCHAR(200) NOT NULL | |
|
||||||
|
| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it |
|
||||||
|
| summary | VARCHAR(500) NULL | |
|
||||||
|
| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is |
|
||||||
|
| image_url | VARCHAR(500) NULL | |
|
||||||
|
| owner_module | VARCHAR(64) NULL | the module that SHIPPED this definition as content — not the module whose actions its steps call. A definition may call three modules' verbs and belong to none of them; NULL is the ordinary case |
|
||||||
|
| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to |
|
||||||
|
| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert |
|
||||||
|
| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below |
|
||||||
|
| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | |
|
||||||
|
| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc |
|
||||||
|
| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once |
|
||||||
|
| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 |
|
||||||
|
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: every listing this replaces is written in the shard's local zone, and a recurrence computed in UTC puts a Friday-8pm event at 7pm for half the year. Validated against the platform's own tzdata via `Intl.DateTimeFormat` |
|
||||||
|
| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter.
|
||||||
|
`INDEX(series_id, series_order)` — the arc.
|
||||||
|
|
||||||
|
**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.**
|
||||||
|
"Editing a draft is free; no version exists yet" means the working copy 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. Publishing copies this
|
||||||
|
column into a version and leaves it standing as the next draft.
|
||||||
|
|
||||||
|
#### event_versions — the immutable snapshot a run pins
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
|
||||||
|
| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 |
|
||||||
|
| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree |
|
||||||
|
| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||||||
|
| published_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
|
||||||
|
**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the
|
||||||
|
*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what
|
||||||
|
makes a run reproducible and an audit answerable after the definition has moved on.
|
||||||
|
|
||||||
|
#### event_runs — one occurrence, in one scope
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
|
||||||
|
| version_id | INT NOT NULL FK→event_versions(id) | **no ON DELETE clause, so it RESTRICTs**: a run whose pinned spec had been deleted could not be explained afterwards, which is the one thing this table is for |
|
||||||
|
| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence |
|
||||||
|
| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal |
|
||||||
|
| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both |
|
||||||
|
| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it |
|
||||||
|
| current_phase | VARCHAR(64) NULL | |
|
||||||
|
| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant |
|
||||||
|
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation |
|
||||||
|
| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params |
|
||||||
|
| params | JSON NULL | |
|
||||||
|
| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history |
|
||||||
|
| started_at / ended_at | DATETIME NULL | |
|
||||||
|
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner |
|
||||||
|
| started_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| last_error | VARCHAR(500) NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per
|
||||||
|
occurrence per scope" true.** The claim decides *who* advances an occurrence; this index is what
|
||||||
|
stops two of them existing. `scope` is inside the key so a worldwide event fans out to many servers
|
||||||
|
without colliding with itself. Materialisation is `INSERT IGNORE` against it, so asking twice for one
|
||||||
|
occurrence answers with the existing row rather than raising a duplicate-key error a caller has to
|
||||||
|
interpret.
|
||||||
|
|
||||||
|
`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list
|
||||||
|
· `INDEX(concurrency_key, status)` the overlap check.
|
||||||
|
|
||||||
|
#### event_run_steps — the work queue
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
|
||||||
|
| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | |
|
||||||
|
| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted |
|
||||||
|
| params | JSON NULL | validated against the action's declared params at save |
|
||||||
|
| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter |
|
||||||
|
| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows |
|
||||||
|
| due_at | DATETIME NULL | |
|
||||||
|
| attempts | INT NOT NULL DEFAULT 0 | |
|
||||||
|
| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run |
|
||||||
|
| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat |
|
||||||
|
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | |
|
||||||
|
| last_error | VARCHAR(500) NULL | |
|
||||||
|
| started_at / finished_at | DATETIME NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran
|
||||||
|
into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan ·
|
||||||
|
`INDEX(run_id, phase, seq)` the run console.
|
||||||
|
|
||||||
|
#### event_run_log — "why didn't phase 3 start?" must be a query
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
|
||||||
|
| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | |
|
||||||
|
| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` |
|
||||||
|
| phase | VARCHAR(64) NULL | |
|
||||||
|
| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable |
|
||||||
|
| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||||||
|
|
||||||
|
`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds.
|
||||||
|
|
||||||
|
**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative
|
||||||
|
audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did*
|
||||||
|
goes here. They are different questions with different readers and different retention. The writer
|
||||||
|
**never throws** — a failure to record why something went wrong must not become a second failure on
|
||||||
|
top of the first.
|
||||||
|
|
||||||
|
**The log is high-cardinality and grows per event, so it needs a retention sweep from the start.**
|
||||||
|
`engagementRetentionPrune` is the pattern and the rule that work learned is that only *terminal* rows
|
||||||
|
are eligible; the sweep lands with the runner, and the index it needs is in the DDL from the
|
||||||
|
beginning.
|
||||||
|
|
||||||
|
#### The action registry — declared, never stored
|
||||||
|
|
||||||
|
Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables,
|
||||||
|
deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one
|
||||||
|
would outlive the module that can perform it. `modules/registries.js` gained
|
||||||
|
`registerEventActions` in this phase, with core as its first registrant —
|
||||||
|
`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is
|
||||||
|
exercised on every boot long before a module uses it.
|
||||||
|
|
||||||
|
The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names
|
||||||
|
a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the
|
||||||
|
closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only
|
||||||
|
iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type
|
||||||
|
and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog
|
||||||
|
serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is
|
||||||
|
naming one by id.
|
||||||
|
|
||||||
|
`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js`
|
||||||
|
builds its own `api` facade for a module and has no method that delegates to it, so no module can
|
||||||
|
call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later
|
||||||
|
phase's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4. API contract
|
## 4. API contract
|
||||||
|
|
||||||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
||||||
@@ -1512,6 +1683,17 @@ file a route sits in — that is the property the route manifest freezes.
|
|||||||
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
|
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
|
||||||
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
||||||
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
|
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
|
||||||
|
| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` |
|
||||||
|
| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders |
|
||||||
|
| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused |
|
||||||
|
| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition |
|
||||||
|
| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins |
|
||||||
|
| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps |
|
||||||
|
| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight |
|
||||||
|
| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` |
|
||||||
|
| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log |
|
||||||
|
| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing |
|
||||||
|
| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase |
|
||||||
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
||||||
|
|
||||||
Every admin write logs to `activity_log`.
|
Every admin write logs to `activity_log`.
|
||||||
|
|||||||
@@ -400,8 +400,8 @@ tables carry no module prefix.
|
|||||||
|
|
||||||
| Table | Holds | Why a table |
|
| 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_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, 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_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_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_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. |
|
| `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_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. |
|
| `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
|
### Not tables, deliberately
|
||||||
|
|
||||||
- **Phases** — configuration in `event_versions.spec`, materialised as steps when a run starts. A
|
- **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.
|
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
|
- **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
|
`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
|
- **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
|
stores verbatim — `ctx.teams.activity.push`'s exact treatment. Core does the remembering; the
|
||||||
module does the meaning.
|
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
|
**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.
|
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
|
- **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,
|
- **Actions and budgets are their own id spaces.** An action names a verb, a trigger names an event,
|
||||||
a budget names a resource dimension.
|
a budget names a resource dimension.
|
||||||
|
|
||||||
@@ -924,7 +940,7 @@ without stealing an edit.
|
|||||||
| Edit | Effect |
|
| Edit | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Editing a `draft` | Free. No version exists yet. |
|
| 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 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. |
|
| 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. |
|
| 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 | |
|
| Route | Gate | |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GET /admin/events` | staff | definitions, state, next occurrence, health |
|
| `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 |
|
| `POST /admin/events` | admin, editor | create a draft |
|
||||||
| `PUT /admin/events/:id` | admin, editor | edit the draft spec |
|
| `PUT /admin/events/:id` | admin, editor | edit the draft spec |
|
||||||
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
|
| `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 |
|
| `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` | 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/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/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` | — | 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/: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
|
> 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.
|
> 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,
|
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.
|
which is what keeps the browser from being able to name a transport.
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,38 @@ document and should be written where they will be found:
|
|||||||
|
|
||||||
### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`)
|
### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`)
|
||||||
|
|
||||||
|
> **Complete.** `edge` in `website` and `docs`. Six tables, thirteen routes, the action registry with
|
||||||
|
> core as its first registrant, and 44 tests. **Nothing dispatches** — a run row is created and stays
|
||||||
|
> `scheduled`, which is this phase's correct answer and is rendered as such.
|
||||||
|
>
|
||||||
|
> **Four things the build settled that the plan had left open, each recorded in `EVENTS.md`:**
|
||||||
|
>
|
||||||
|
> - **`event_definitions` gained a `spec` column.** §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 working copy has to live somewhere, and it cannot be an `event_versions` row: that table
|
||||||
|
> is immutable and a run pins one. Publishing copies the column into a version and leaves it as the
|
||||||
|
> next draft.
|
||||||
|
> - **The spec validator must accept its own output**, and a test found it did not. `validate()` adds
|
||||||
|
> `actionVersion` and `dormant`, then refused them as unknown keys on the next call — which would
|
||||||
|
> have made the *second* save of any definition, and publish's own re-validation, impossible. Both
|
||||||
|
> are now accepted and recomputed rather than trusted.
|
||||||
|
> - **A param's `example` is required**, on optional params too, matching `registerEventTriggers`. It
|
||||||
|
> is the authoring form's placeholder and there is no other source for one.
|
||||||
|
> - **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). Both are staff reads over data the list already exposes.
|
||||||
|
>
|
||||||
|
> **Two deliberate absences, both stated so a reviewer does not read them as gaps.** The live run
|
||||||
|
> controls and `verify` are not stubbed — nothing is in flight until P2, and a control that answers
|
||||||
|
> `200` and does nothing is worse than one that is not there. And core's three `perform()` bodies
|
||||||
|
> answer `{ ok: false, retry: false }` rather than `{ ok: true }`: `ok: true` on an action that did
|
||||||
|
> nothing is a recorded world change that did not occur, which is the exact mistake §F's failure
|
||||||
|
> default exists to prevent.
|
||||||
|
>
|
||||||
|
> `registerEventActions` is on the staging area and reachable **only** by `registerCore()` — the
|
||||||
|
> loader builds its own `api` facade and has no method that delegates to it, so no module can call it
|
||||||
|
> yet and `MODULE_API_VERSION` is untouched. P7 adds that facade and makes the bump.
|
||||||
|
|
||||||
The six tables that do not depend on the module contract: `event_definitions`, `event_series`,
|
The six tables that do not depend on the module contract: `event_definitions`, `event_series`,
|
||||||
`event_versions`, `event_runs`, `event_run_steps`, `event_run_log`. Admin CRUD, publish (which
|
`event_versions`, `event_runs`, `event_run_steps`, `event_run_log`. Admin CRUD, publish (which
|
||||||
snapshots a version), archive. `router/v1/admin/events.router.js` + `events.controller.js`, models as
|
snapshots a version), archive. `router/v1/admin/events.router.js` + `events.controller.js`, models as
|
||||||
|
|||||||
Reference in New Issue
Block a user