From 9ba119dc5acce1838ff0a7ceb2acefdbec2aadef Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 23:28:26 -0500 Subject: [PATCH] =?UTF-8?q?docs(events):=20Phase=201=20as=20built=20?= =?UTF-8?q?=E2=80=94=20schema,=20CRUD=20and=20the=20core=20action=20regist?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs half of RunicGateway/website#. 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 --- website/BACKEND_DESIGN.md | 182 ++++++++++++++++++++++++++++++++++++++ website/EVENTS.md | 43 +++++++-- website/EVENTS_PLAN.md | 32 +++++++ 3 files changed, 252 insertions(+), 5 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 1c91346..aa22c86 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 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 | `/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 | `/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) | Every admin write logs to `activity_log`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 1e8c0b8..7811318 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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. diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 2915575..ef78084 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -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`) +> **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`, `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 -- 2.49.1