From 8e03497eb320f6c74ba8c7c3bb658e44d0fe899d Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 1 Sep 2026 23:29:07 -0500
Subject: [PATCH 01/18] feat(events): schema, CRUD and the core action registry
(Phase 1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.
**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.
Schema (`db/schema.sql`, append-only):
event_series, event_definitions, event_versions, event_runs,
event_run_steps, event_run_log. The four that need a writer —
event_action_settings, event_run_budget, event_run_resources,
event_run_participants — arrive with the phases that give them one.
Registry (`modules/registries.js` + `config/coreEventActions.js`):
registerEventActions staging and commit, with its own id namespace, the
closed risk and reversibility sets, revert() required iff and only iff
reversible: 'ledger', a bounded budgetMs and a param shape whose every
entry needs a type and an example. perform/revert/cost are stripped from
everything the catalog serves. Core declares core.announce, core.wait and
core.cue through the same staging area a module will use.
It is reachable ONLY by registerCore(): loader.js builds its own api facade
and has no method that delegates here, so no module can call it and
MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.
Surface (13 routes under /api/v1/admin/events):
Reads staff-wide; publish, archive and run creation admin-only from this
phase per EVENTS.md §N2, even though the switchboard they will consult does
not exist yet — a button that is admin-only later and open now is a gate
nobody notices was missing. The live run controls and `verify` are absent
rather than stubbed, because nothing is in flight yet.
Four things the build settled, all recorded in docs:
- event_definitions gained a `spec` column. A draft's working copy cannot
be an event_versions row: that table is immutable and a run pins one.
- The spec validator must accept its own output. It added `actionVersion`
and `dormant` and then refused them as unknown keys, which would have made
the second save of any definition — and publish's re-validation —
impossible. A test caught it; both are now accepted and recomputed.
- A param's `example` is required, optional params included, matching
registerEventTriggers. It is the authoring form's placeholder.
- Two routes the §API-surface table did not name: GET /admin/events/:id and
GET /admin/events/series.
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.
`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.
Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).
Docs: RunicGateway/docs#209
Co-Authored-By: Claude
---
server/db/schema.sql | 266 ++++
server/routes.guards.json | 117 ++
server/routes.manifest.json | 52 +
server/src/config/coreEventActions.js | 153 +++
server/src/engagement/conditions.js | 16 +-
server/src/events/spec.js | 317 +++++
.../src/model/events/eventDefinitions.db.js | 134 ++
.../model/events/eventDefinitions.model.js | 270 ++++
server/src/model/events/eventJson.js | 23 +
server/src/model/events/eventRunLog.db.js | 67 +
server/src/model/events/eventRunSteps.db.js | 95 ++
server/src/model/events/eventRuns.db.js | 105 ++
server/src/model/events/eventRuns.model.js | 134 ++
server/src/model/events/eventSeries.db.js | 23 +
server/src/model/events/eventVersions.db.js | 54 +
server/src/modules/registries.js | 254 ++++
.../src/router/v1/admin/events.controller.js | 315 +++++
server/src/router/v1/admin/events.router.js | 201 +++
server/src/router/v1/admin/index.js | 8 +
server/swagger/swagger-output.json | 1087 +++++++++++++++++
server/test/eventActionRegistry.test.js | 196 +++
server/test/eventSpec.test.js | 231 ++++
server/test/eventsAdmin.test.js | 597 +++++++++
23 files changed, 4714 insertions(+), 1 deletion(-)
create mode 100644 server/src/config/coreEventActions.js
create mode 100644 server/src/events/spec.js
create mode 100644 server/src/model/events/eventDefinitions.db.js
create mode 100644 server/src/model/events/eventDefinitions.model.js
create mode 100644 server/src/model/events/eventJson.js
create mode 100644 server/src/model/events/eventRunLog.db.js
create mode 100644 server/src/model/events/eventRunSteps.db.js
create mode 100644 server/src/model/events/eventRuns.db.js
create mode 100644 server/src/model/events/eventRuns.model.js
create mode 100644 server/src/model/events/eventSeries.db.js
create mode 100644 server/src/model/events/eventVersions.db.js
create mode 100644 server/src/router/v1/admin/events.controller.js
create mode 100644 server/src/router/v1/admin/events.router.js
create mode 100644 server/test/eventActionRegistry.test.js
create mode 100644 server/test/eventSpec.test.js
create mode 100644 server/test/eventsAdmin.test.js
diff --git a/server/db/schema.sql b/server/db/schema.sql
index f7fa01b..b1e3bea 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -2083,3 +2083,269 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
INDEX idx_engsup_created (created_at),
INDEX idx_engsup_reason (reason, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
+
+-- Six of the nine core tables land here: 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 (P6, P8, P10) rather than as empty tables nothing reads.
+--
+-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
+-- action id, a scope, a resource kind and a budget dimension are all opaque
+-- strings core stores and never interprets (§C).
+
+-- The arc. Definitions optionally belong to one, and the series is what carries
+-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From
+-- the Void" is a thing the tooling this replaces cannot express at all.
+--
+-- The table lands in Phase 1 because `event_definitions.series_id` points at it;
+-- the routes that create and order one are Phase 4's, where the calendar makes
+-- an arc visible.
+CREATE TABLE IF NOT EXISTS event_series (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(160) NOT NULL,
+ slug VARCHAR(160) NOT NULL,
+ description TEXT NULL,
+ -- Where this series sits among the others on the calendar. Not a position
+ -- WITHIN the series: a definition's place in its arc is `event_definitions`'
+ -- own `series_order` below, because that is the column an editor drags.
+ ordering INT NOT NULL DEFAULT 0,
+ created_by INT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE KEY uq_evser_slug (slug)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- The thing that is listed, searched, scheduled and audited.
+--
+-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an
+-- admin publishes their own work, so there is nobody to submit it to, and a
+-- review state nobody uses is a state every query has to remember anyway.
+--
+-- `owner_module` is NULLable and is 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 means an operator
+-- authored it here, which is the ordinary case.
+--
+-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value
+-- (`invasion:{region}`), because it is rendered from a run's own params at
+-- materialisation (§E). A flat definition-id key would wrongly stop one
+-- definition running in two regions at once.
+--
+-- `current_version_id` carries NO foreign key, deliberately, and it is the one
+-- column in this group without one: `event_versions.definition_id` already
+-- points back here, and a second FK in the other direction makes the pair a
+-- chicken and an egg on insert.
+CREATE TABLE IF NOT EXISTS event_definitions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(200) NOT NULL,
+ slug VARCHAR(200) NOT NULL,
+ summary VARCHAR(500) NULL,
+ -- The storyline. Sanitized HTML, the same treatment a wiki page gets.
+ body MEDIUMTEXT NULL,
+ image_url VARCHAR(500) NULL,
+ owner_module VARCHAR(64) NULL,
+ state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
+ current_version_id INT NULL,
+ -- The WORKING COPY of the spec - phases and their steps - as the author last
+ -- saved it. §D's column list does not name it because §D describes what a
+ -- PUBLISHED event is made of, and a version row is where a spec ends up. But
+ -- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning")
+ -- has to mean the draft lives 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 the exact thing versioning exists to prevent. Publishing
+ -- copies this column into a version and leaves it here as the next draft.
+ spec JSON NOT NULL,
+ series_id INT NULL,
+ series_order INT NOT NULL DEFAULT 0,
+ concurrency_key VARCHAR(190) NULL,
+ -- The grace window (§E). A schedule that passed this many seconds ago while the
+ -- process was down is `missed`, never a late silent start.
+ grace_seconds INT NOT NULL DEFAULT 900,
+ -- IANA, and it belongs to the EVENT rather than to the viewer: 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.
+ timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
+ created_by INT NULL,
+ updated_by INT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL,
+ CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE KEY uq_evdef_slug (slug),
+ -- The admin list's default ordering, and the public calendar's filter.
+ INDEX idx_evdef_state (state, updated_at),
+ INDEX idx_evdef_series (series_id, series_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- An immutable snapshot of a whole definition spec: phases, steps, schedule,
+-- conditions, announcements. A run pins one, and that pin is the entire reason
+-- this table exists — it is what makes a run reproducible, and an audit
+-- answerable, after the definition has been edited underneath it.
+--
+-- Nothing updates a row here. Editing a `ready` definition creates the NEXT
+-- version on publish; a live run keeps the version it pinned and is unaffected
+-- (EVENTS.md "Versioning, and editing a live event").
+CREATE TABLE IF NOT EXISTS event_versions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ definition_id INT NOT NULL,
+ version INT NOT NULL,
+ spec JSON NOT NULL,
+ published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ published_by INT NULL,
+ CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
+ CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
+ -- Two publishes racing for version 4 is one 1062, not two rows called 4.
+ UNIQUE KEY uq_evver_def_version (definition_id, version)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- One occurrence of one definition, in one scope.
+--
+-- The UNIQUE key below — not the claim — is what makes "one run per occurrence
+-- per scope" true (§E). 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, and it is
+-- module-opaque: core stores the string and never parses it.
+--
+-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was
+-- COMPUTED in (Phase 4); what is stored is the instant.
+--
+-- `health` is a separate column from `status` because a run can be genuinely
+-- running and degraded at once — announcements landing, world writes parked —
+-- and one column cannot say both. `cleanup_status` is separate for the mirror
+-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather
+-- than being held open, and stays on the admin screen until a human resolves it.
+--
+-- `version_id`'s foreign key has 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.
+CREATE TABLE IF NOT EXISTS event_runs (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ definition_id INT NOT NULL,
+ version_id INT NOT NULL,
+ -- Module-opaque, and '' rather than NULL for the single-scope case: it is part
+ -- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL
+ -- scope would silently permit two runs of one occurrence.
+ scope VARCHAR(190) NOT NULL DEFAULT '',
+ status ENUM('scheduled','starting','running','paused','ending',
+ 'completed','cancelled','failed','missed')
+ NOT NULL DEFAULT 'scheduled',
+ health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
+ cleanup_status ENUM('not_required','pending','complete','incomplete')
+ NOT NULL DEFAULT 'not_required',
+ current_phase VARCHAR(64) NULL,
+ scheduled_for DATETIME NOT NULL,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
+ concurrency_key VARCHAR(190) NULL, -- rendered from this run's params
+ params JSON NULL,
+ -- A rehearsal dispatches for real but is excluded from the public calendar and
+ -- from participation history. Declared here in Phase 1 so the column exists
+ -- before anything can create a run without it.
+ rehearsal TINYINT(1) NOT NULL DEFAULT 0,
+ started_at DATETIME NULL,
+ ended_at DATETIME NULL,
+ claimed_by VARCHAR(64) NULL,
+ claim_expires_at DATETIME NULL,
+ started_by INT NULL,
+ last_error VARCHAR(500) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
+ CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id),
+ CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
+ UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
+ -- The runner's materialise/advance scan: due runs by status.
+ INDEX idx_evrun_due (status, scheduled_for),
+ -- The admin run list, newest first, and the per-definition history.
+ INDEX idx_evrun_def (definition_id, scheduled_for),
+ -- Phase 2's overlap check. NULL keys are skipped by the index, which is right:
+ -- a definition with no key never contends.
+ INDEX idx_evrun_concurrency (concurrency_key, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- The work queue: one action invocation, claimed with the outbox's
+-- compare-and-set. This is the shape `engagement_outbox` already proved, and
+-- retries, timeouts, duplicate execution and resumption are all properties of
+-- this row rather than of a scheduler's memory.
+--
+-- `idempotency_key` is minted ONCE at materialisation and does not vary by
+-- attempt (§E) — a retry re-sends the same key so the game side can recognise
+-- the repeat. It is generated by core rather than by the module because core is
+-- what guarantees its stability.
+--
+-- `action_version` records what the step was AUTHORED against. A module that
+-- bumps its action makes the step render a warning in the editor rather than
+-- dispatch a mistyped parameter.
+--
+-- `refused` is in the status set and is deliberately not `failed`: a cap breach
+-- means nothing is wrong with the system, and an author asked for more than this
+-- deployment allows.
+CREATE TABLE IF NOT EXISTS event_run_steps (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ run_id BIGINT NOT NULL,
+ phase VARCHAR(64) NOT NULL,
+ seq INT NOT NULL,
+ action_id VARCHAR(96) NOT NULL,
+ params JSON NULL,
+ action_version INT NOT NULL DEFAULT 1,
+ status ENUM('pending','running','done','failed','skipped','refused','cancelled')
+ NOT NULL DEFAULT 'pending',
+ due_at DATETIME NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ -- Defaulted from the action's risk class at materialisation (§L): retry->skip
+ -- for notify, retry->pause for change, retry->abort_run for irreversible.
+ on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
+ idempotency_key CHAR(40) NOT NULL,
+ claimed_by VARCHAR(64) NULL,
+ claim_expires_at DATETIME NULL,
+ last_error VARCHAR(500) NULL,
+ started_at DATETIME NULL,
+ finished_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
+ -- The drain scan, exactly as written: due, pending, oldest first.
+ INDEX idx_evstep_due (status, due_at),
+ -- The run console: every step of one run in authored order.
+ INDEX idx_evstep_run (run_id, phase, seq),
+ -- Materialisation is INSERT IGNORE against this, so a tick that overruns into
+ -- the next one cannot double-materialise a phase.
+ UNIQUE KEY uq_evstep_slot (run_id, phase, seq)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- "Why didn't phase 3 start?" must be a query.
+--
+-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this
+-- table exists rather than the audit log being reused: an operator diagnosing a
+-- stalled phase needs to filter by kind and read structured detail, and an
+-- administrative audit of WHO published WHAT is a different question with a
+-- different retention. Both are written — the audit to `activity_log`, the
+-- diagnosis here.
+--
+-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM,
+-- because the set grows with almost every later phase and an ENUM change is a
+-- table alter this project has no migration system for.
+--
+-- 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 itself lands
+-- with the runner in Phase 2; the index it needs is here from the beginning.
+CREATE TABLE IF NOT EXISTS event_run_log (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ run_id BIGINT NOT NULL,
+ step_id BIGINT NULL,
+ kind VARCHAR(48) NOT NULL,
+ phase VARCHAR(64) NULL,
+ detail JSON NULL,
+ at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
+ CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
+ -- The run console reads this whole index and nothing else.
+ INDEX idx_evlog_run (run_id, at),
+ -- What the Phase 2 retention sweep queries. Without it the sweep is a table
+ -- scan of every line this deployment has ever logged.
+ INDEX idx_evlog_at (at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 56551b2..c53a92f 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -419,6 +419,123 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/events/:id",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/:id",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/events/:id",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/:id/publish",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/:id/runs",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/:id/versions",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/catalog",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/runs",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/runs/:runId",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/runs/:runId/log",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/series",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/invites",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 4dce8bf..1adec3c 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -185,6 +185,58 @@
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/events/:id"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/:id"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/events/:id"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/:id/publish"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/:id/runs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/:id/versions"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/catalog"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/runs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/runs/:runId"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/runs/:runId/log"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/series"
+ },
{
"method": "GET",
"path": "/api/v1/admin/invites"
diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js
new file mode 100644
index 0000000..d26eb21
--- /dev/null
+++ b/server/src/config/coreEventActions.js
@@ -0,0 +1,153 @@
+// ── Core's own event actions ───────────────────────────────────────────────
+//
+// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
+// and registered through the same staging area a module will use in Phase 7 —
+// which is the entire reason these three exist this early. A registry whose first
+// real registrant is a module is a registry that has already drifted, and §F's
+// claim that core is "an event engine that can announce, wait, cue a human and
+// publish results" with NO module installed is only true if core declares the
+// verbs that do it.
+//
+// **Three actions, and between them they cover the three things an event can do
+// that name no game noun at all**: tell people something, let time pass, and ask
+// a human to go and do something. A deployment with no game module installed has
+// a working event system made of exactly these.
+//
+// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar,
+// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js`
+// and is what calls `perform()`. The bodies below therefore answer with the
+// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`,
+// which is the one wrong answer a placeholder can give: `ok: true` on an action
+// that did nothing is a recorded world change that did not occur, which is the
+// exact mistake the envelope's failure default exists to prevent. `retry: false`
+// because a missing runner is not a transient condition.
+//
+// **This file must not touch the database.** It is required from `registerCore()`,
+// which runs under `routeManifest.js` and `swagger.js` against a dead pool
+// (MODULE_API.md §2.2). It is pure data plus three functions that are not called.
+
+// A factory rather than one shared function, because `perform`'s argument is
+// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params,
+// actor, verify }` — and it does not carry the action's own id. Closing over it
+// is what lets the refusal name which action refused.
+const notWiredYet = (actionId) => async () => ({
+ ok: false,
+ retry: false,
+ error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`,
+})
+
+const ACTIONS = [
+ {
+ id: 'core.announce',
+ label: 'Announce',
+ description:
+ 'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
+
+ // Nothing in the world changes and nothing is created: a message goes out.
+ // That is what makes the default `on_failure` for this step `retry -> skip`
+ // (§L) rather than `pause`, and it is the honest class even though the
+ // message itself cannot be unsent.
+ risk: 'notify',
+ // A sent announcement is gone. `none` rather than `ledger` is not an
+ // omission — there is no undo to write, and declaring `ledger` would put a
+ // row in the cleanup ledger that teardown could never resolve.
+ reversible: 'none',
+ version: 1,
+
+ params: [
+ {
+ // A leg id, checked against the announce-leg registry at dispatch rather
+ // than here: legs are registered by modules, and this file is evaluated
+ // before any module has registered anything.
+ name: 'leg',
+ type: 'string',
+ required: true,
+ example: 'discord',
+ description: 'The announce leg to publish on. Registered legs only.',
+ },
+ {
+ name: 'title',
+ type: 'string',
+ required: false,
+ example: 'The gates of Britain open at dusk',
+ description: 'Optional heading, for legs that render one.',
+ },
+ {
+ name: 'body',
+ type: 'string',
+ required: true,
+ example: 'A caravan has been sighted on the road east of Cove.',
+ description: 'The announcement itself. Plain text.',
+ },
+ ],
+
+ perform: notWiredYet('core.announce'),
+ },
+
+ {
+ id: 'core.wait',
+ label: 'Wait',
+ description: 'Let a fixed amount of time pass before the next step of this phase runs.',
+
+ // `inspect` rather than `notify`: nothing is sent and nobody is told. It is
+ // the weakest class the closed set has for an action that is not a broadcast.
+ risk: 'inspect',
+ reversible: 'none',
+ version: 1,
+
+ params: [
+ {
+ name: 'seconds',
+ type: 'int',
+ required: true,
+ example: 300,
+ description: 'How long to wait. The runner sets the next step due_at from this.',
+ },
+ ],
+
+ // A wait is a genuine no-op at dispatch, and it will stay one: the delay is
+ // the NEXT step's `due_at`, which the runner owns, not something this
+ // function sleeps through. A `perform` that slept would hold a step's claim
+ // for the duration and turn a five-minute pause into a five-minute lease.
+ perform: notWiredYet('core.wait'),
+ },
+
+ {
+ id: 'core.cue',
+ label: 'Cue a human',
+ description:
+ 'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
+
+ // The action itself only posts an instruction. Whatever the human then does
+ // is outside this system entirely, which is precisely why the cue exists:
+ // it is how an event uses a capability no module has automated.
+ risk: 'notify',
+ reversible: 'none',
+ version: 1,
+
+ params: [
+ {
+ name: 'instruction',
+ type: 'string',
+ required: true,
+ example: 'Open the north gate and read the herald script in Britain bank.',
+ description: 'What the staff member is being asked to do.',
+ },
+ {
+ name: 'assignee',
+ type: 'string',
+ required: false,
+ example: 'Event Team',
+ description: 'Who the cue is addressed to. A label, not an account.',
+ },
+ ],
+
+ // Phase 2 gives this its parking semantics — a cue step does not complete
+ // when `perform` answers, it completes when a human presses confirm, and the
+ // control that does so is Phase 3's. Both of those are what make this the
+ // one action whose runtime shape is deliberately not decided here.
+ perform: notWiredYet('core.cue'),
+ },
+]
+
+module.exports = { ACTIONS }
diff --git a/server/src/engagement/conditions.js b/server/src/engagement/conditions.js
index 2f99031..e6e182c 100644
--- a/server/src/engagement/conditions.js
+++ b/server/src/engagement/conditions.js
@@ -248,4 +248,18 @@ const vocabulary = () =>
/** Convenience for a caller holding only a trigger id. */
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
-module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }
+// `checkLiteral` is exported for the event system's step-param validator
+// (EVENTS.md §F, Phase 1), which checks an authored param value against an
+// action's declared param type — the same six types over the same coercion. A
+// second copy of this switch would be a second answer to "is this a datetime",
+// and the two would drift on the first zone-suffixed string somebody typed.
+module.exports = {
+ validate,
+ validateFor,
+ evaluate,
+ vocabulary,
+ checkLiteral,
+ OPERATORS,
+ MAX_LIST,
+ MAX_DEPTH,
+}
diff --git a/server/src/events/spec.js b/server/src/events/spec.js
new file mode 100644
index 0000000..7551d96
--- /dev/null
+++ b/server/src/events/spec.js
@@ -0,0 +1,317 @@
+// ── The event spec, and the one place it is validated ──────────────────────
+//
+// EVENTS.md §C ("phases and actions are configuration inside a version snapshot,
+// not tables") and §D. A spec is the authored tree a definition carries and a
+// version freezes: phases, and the steps inside them. It is stored as JSON in
+// `event_definitions`' working copy and in `event_versions.spec`, and this file
+// is the only thing that decides whether one is well formed.
+//
+// **Every check here is a boundary, not a convenience.** The authoring UI (Phase
+// 3, then Phase 13) will re-implement some of them for the sake of a good
+// inline error, and that second copy is expected to drift — so this one is the
+// one that decides. A spec arriving by any other route (a restore, a fixture, a
+// module shipping a definition as content) gets the same answer.
+//
+// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys
+// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind:
+// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence
+// in an IANA zone and a spec that could name `weekly` before then would be a
+// schedule nothing honours. Unknown top-level keys are REFUSED rather than
+// preserved: a spec that silently carries `announcements` today is a spec whose
+// author believes announcements work, and the later phase that gives the key
+// meaning would inherit a corpus of unvalidated ones. The refusal list is the
+// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's
+// `advance`, Phase 10 adds `announcements`.
+
+const registries = require('../modules/registries')
+const { checkLiteral } = require('../engagement/conditions')
+
+// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
+// run console groups by, and it is what an operator reads in "phase 3 has not
+// started". Same grammar as a template key's segment.
+const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
+const MAX_PHASE_KEY = 64
+
+// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal
+// with a number in it rather than a run that materialises fifty thousand step
+// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side.
+const MAX_PHASES = 40
+const MAX_STEPS_PER_PHASE = 100
+const MAX_STEPS = 500
+
+// The schedule shapes this phase understands. Phase 4 replaces this list with
+// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and
+// their timezone arithmetic. It is a list of one rather than an implicit default
+// so that the widening is a diff on this line.
+const SCHEDULE_KINDS = ['manual']
+
+// What a step does when its attempts are exhausted (§L). The disposition only —
+// retry is not one of the values, it is what happens BEFORE one of them. Each
+// risk class has a default, which is the whole reason `risk` is required at
+// registration: a `change` action that fell back to `skip` would leave a run
+// advancing over a half-changed world.
+const ON_FAILURE = ['skip', 'pause', 'abort_run']
+const ON_FAILURE_BY_RISK = {
+ notify: 'skip',
+ inspect: 'skip',
+ change: 'pause',
+ irreversible: 'abort_run',
+}
+
+const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
+
+/** The default disposition for an action whose risk class core knows. */
+const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
+
+/**
+ * Check one authored param object against an action's declared params.
+ *
+ * Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent
+ * drop: an author who typed `creatures` where the action declares `creature` has
+ * written a step that would dispatch with the count missing, and dropping the key
+ * makes that look like it saved cleanly.
+ */
+function checkParams(declaration, raw, path) {
+ const errors = []
+ const params = {}
+ const given = isPlainObject(raw) ? raw : {}
+
+ if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
+ return { params, errors: [`${path}.params: expected an object`] }
+ }
+
+ const declared = new Map((declaration.params || []).map((p) => [p.name, p]))
+ for (const name of Object.keys(given)) {
+ if (!declared.has(name)) {
+ errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`)
+ }
+ }
+
+ for (const p of declaration.params || []) {
+ const value = given[p.name]
+ if (value === undefined || value === null || value === '') {
+ if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
+ continue
+ }
+ const checked = checkLiteral(p.type, value)
+ if (checked.error) {
+ errors.push(`${path}.params: "${p.name}" ${checked.error}`)
+ continue
+ }
+ params[p.name] = checked.value
+ }
+
+ return { params, errors }
+}
+
+/**
+ * Validate and normalise a whole spec.
+ *
+ * `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }`
+ * listing EVERY problem rather than the first — the posture `conditions.validate`
+ * takes, and for the same reason: an author fixing one step at a time is an
+ * author making six round trips through a form.
+ *
+ * `knownActionIds` widens what may be named beyond what is registered right now,
+ * and it is how a definition survives its module being uninstalled. The rule,
+ * lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger:
+ * **a step already in the saved spec may keep an unregistered action; a new step
+ * may not add one.** Refusing to save the whole definition would make an
+ * uninstall destructive after the fact, and silently dropping the step would
+ * delete authored work to make a form submit. A kept step is marked
+ * `dormant: true` and its params pass through unvalidated, because the only
+ * thing that could validate them left with the module.
+ */
+function validate(raw, { knownActionIds = [] } = {}) {
+ const errors = []
+ const known = new Set(knownActionIds)
+
+ if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
+
+ const allowed = new Set(['schedule', 'phases'])
+ for (const key of Object.keys(raw)) {
+ if (!allowed.has(key)) {
+ errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
+ }
+ }
+
+ // ── schedule ──
+ const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
+ let schedule = { kind: 'manual' }
+ if (!isPlainObject(rawSchedule)) {
+ errors.push('spec.schedule: expected an object')
+ } else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
+ errors.push(
+ `spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`,
+ )
+ } else {
+ const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind')
+ if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`)
+ schedule = { kind: rawSchedule.kind }
+ }
+
+ // ── phases ──
+ const rawPhases = raw.phases
+ const phases = []
+ if (!Array.isArray(rawPhases) || rawPhases.length === 0) {
+ errors.push('spec.phases: expected a non-empty array')
+ return { ok: false, errors }
+ }
+ if (rawPhases.length > MAX_PHASES) {
+ errors.push(`spec.phases: at most ${MAX_PHASES} phases`)
+ return { ok: false, errors }
+ }
+
+ const seenKeys = new Set()
+ let totalSteps = 0
+
+ rawPhases.forEach((rawPhase, pi) => {
+ const path = `spec.phases[${pi}]`
+ if (!isPlainObject(rawPhase)) {
+ errors.push(`${path}: expected an object`)
+ return
+ }
+ const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k))
+ if (extra.length) {
+ errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`)
+ }
+
+ const key = rawPhase.key
+ if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
+ errors.push(`${path}.key: bad phase key "${key}"`)
+ } else if (seenKeys.has(key)) {
+ // Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two
+ // phases sharing a key would silently collapse into one at materialisation
+ // and half the authored steps would never exist.
+ errors.push(`${path}.key: "${key}" is used by more than one phase`)
+ } else {
+ seenKeys.add(key)
+ }
+
+ if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`)
+
+ const rawSteps = rawPhase.steps
+ if (!Array.isArray(rawSteps)) {
+ errors.push(`${path}.steps: expected an array`)
+ return
+ }
+ if (rawSteps.length > MAX_STEPS_PER_PHASE) {
+ errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`)
+ return
+ }
+ totalSteps += rawSteps.length
+
+ const steps = []
+ rawSteps.forEach((rawStep, si) => {
+ const spath = `${path}.steps[${si}]`
+ if (!isPlainObject(rawStep)) {
+ errors.push(`${spath}: expected an object`)
+ return
+ }
+ // `actionVersion` and `dormant` are in this list because **validate must
+ // accept its own output**. A saved spec is re-validated on every later
+ // save and again at publish, so a normalised field that the validator
+ // itself added and then refused would make the second save of any
+ // definition impossible. They are accepted and then RECOMPUTED below
+ // rather than trusted: the version comes from the declaration, and
+ // dormancy from whether anyone registers the action right now.
+ const stepExtra = Object.keys(rawStep).filter(
+ (k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k),
+ )
+ if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`)
+
+ const actionId = rawStep.actionId
+ const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null
+
+ if (typeof actionId !== 'string' || !actionId) {
+ errors.push(`${spath}.actionId: a step needs an action`)
+ return
+ }
+ if (!declaration && !known.has(actionId)) {
+ errors.push(`${spath}.actionId: no module registers "${actionId}"`)
+ return
+ }
+
+ if (!declaration) {
+ // Dormant: kept verbatim, params untouched, and flagged so the editor and
+ // the run console can both say WHY rather than showing an empty step.
+ steps.push({
+ actionId,
+ label: rawStep.label || actionId,
+ params: isPlainObject(rawStep.params) ? rawStep.params : {},
+ actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1,
+ onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause',
+ dormant: true,
+ })
+ return
+ }
+
+ const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
+ errors.push(...paramErrors)
+
+ if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
+ errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
+ }
+
+ steps.push({
+ actionId,
+ label: rawStep.label || declaration.label,
+ params,
+ // Captured at SAVE time, from the declaration this step was authored
+ // against (§F). It is what lets a later bump render a warning in the
+ // editor instead of dispatching a mistyped parameter.
+ actionVersion: declaration.version,
+ onFailure: ON_FAILURE.includes(rawStep.onFailure)
+ ? rawStep.onFailure
+ : defaultOnFailure(declaration.risk),
+ dormant: false,
+ })
+ })
+
+ phases.push({ key, label: rawPhase.label, steps })
+ })
+
+ if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
+
+ if (errors.length) return { ok: false, errors }
+ return { ok: true, spec: { schedule, phases } }
+}
+
+/** Every action id a spec names, dormant ones included. */
+const actionIdsIn = (spec) =>
+ (spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean)
+
+/**
+ * A spec is publishable when nothing in it is dormant.
+ *
+ * Separate from `validate` on purpose: a dormant step must not stop an author
+ * SAVING (that is what makes an uninstall non-destructive), and it must stop
+ * them PUBLISHING, because publishing is what makes a version a thing runs are
+ * pinned to and a run cannot dispatch a verb nobody registers.
+ */
+function publishable(spec) {
+ const dormant = (spec?.phases || [])
+ .flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId))
+ return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
+}
+
+/** An empty, valid spec — what a newly created draft carries. */
+const emptySpec = () => ({
+ schedule: { kind: 'manual' },
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+})
+
+module.exports = {
+ validate,
+ publishable,
+ actionIdsIn,
+ emptySpec,
+ defaultOnFailure,
+ PHASE_KEY,
+ SCHEDULE_KINDS,
+ ON_FAILURE,
+ ON_FAILURE_BY_RISK,
+ MAX_PHASES,
+ MAX_STEPS_PER_PHASE,
+ MAX_STEPS,
+}
diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js
new file mode 100644
index 0000000..0794cfe
--- /dev/null
+++ b/server/src/model/events/eventDefinitions.db.js
@@ -0,0 +1,134 @@
+// ── event_definitions — SQL only ───────────────────────────────────────────
+//
+// EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration,
+// no policy. Everything that decides whether a write is allowed lives in
+// `eventDefinitions.model.js`.
+
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+const hydrate = (row) =>
+ row && {
+ ...row,
+ spec: parseJson(row.spec, null),
+ }
+
+// `current_version` is joined rather than stored: the list screen shows "v3" and
+// the column that would hold it is a denormalisation of a row this query already
+// has to reach for the publish date anyway.
+const SELECT_LIST = `
+ SELECT d.*, s.name AS series_name, s.slug AS series_slug,
+ v.version AS current_version
+ FROM event_definitions d
+ LEFT JOIN event_series s ON s.id = d.series_id
+ LEFT JOIN event_versions v ON v.id = d.current_version_id
+`
+
+const list = async ({ state = null } = {}) => {
+ const rows = state
+ ? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state])
+ : await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`)
+ return rows.map(hydrate)
+}
+
+const getById = async (id) => {
+ const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id])
+ return hydrate(row)
+}
+
+const getBySlug = async (slug) => {
+ const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug])
+ return hydrate(row)
+}
+
+/** Does any OTHER definition hold this slug? The uniqueness pre-check. */
+const slugTaken = async (slug, exceptId = null) => {
+ const rows = exceptId
+ ? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId])
+ : await query('SELECT id FROM event_definitions WHERE slug = ?', [slug])
+ return rows.length > 0
+}
+
+const insert = async (d) => {
+ const result = await query(
+ `INSERT INTO event_definitions
+ (title, slug, summary, body, image_url, owner_module, series_id, series_order,
+ concurrency_key, grace_seconds, timezone, spec, created_by, updated_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ d.title,
+ d.slug,
+ d.summary,
+ d.body,
+ d.image_url,
+ d.owner_module,
+ d.series_id,
+ d.series_order,
+ d.concurrency_key,
+ d.grace_seconds,
+ d.timezone,
+ JSON.stringify(d.spec),
+ d.created_by,
+ d.created_by,
+ ],
+ )
+ return result.insertId
+}
+
+const update = (id, d) =>
+ query(
+ `UPDATE event_definitions
+ SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
+ series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
+ spec = ?, updated_by = ?
+ WHERE id = ?`,
+ [
+ d.title,
+ d.slug,
+ d.summary,
+ d.body,
+ d.image_url,
+ d.series_id,
+ d.series_order,
+ d.concurrency_key,
+ d.grace_seconds,
+ d.timezone,
+ JSON.stringify(d.spec),
+ d.updated_by,
+ id,
+ ],
+ )
+
+/**
+ * Point a definition at the version it just published, and mark it `ready`.
+ *
+ * One statement, because the two halves are the same fact: `ready` means "a
+ * version has been published and the schedule is live" (§E), so a state without
+ * a `current_version_id` is a lie the scheduler would act on.
+ */
+const markReady = (id, versionId, userId) =>
+ query(
+ `UPDATE event_definitions
+ SET state = 'ready', current_version_id = ?, updated_by = ?
+ WHERE id = ?`,
+ [versionId, userId, id],
+ )
+
+/**
+ * Archive. Never a hard delete while runs reference it (§ API surface) — and the
+ * schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
+ * Archiving is what "delete" means on this screen, and the row keeps its history.
+ */
+const archive = (id, userId) =>
+ query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id])
+
+module.exports = {
+ list,
+ getById,
+ getBySlug,
+ slugTaken,
+ insert,
+ update,
+ markReady,
+ archive,
+}
diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js
new file mode 100644
index 0000000..be5008f
--- /dev/null
+++ b/server/src/model/events/eventDefinitions.model.js
@@ -0,0 +1,270 @@
+// ── Event definitions — the save path ──────────────────────────────────────
+//
+// EVENTS.md §D and "Versioning, and editing a live event". A definition is
+// operator-authored data, and this file is the boundary that decides whether a
+// version of it may exist. The authoring UI (Phase 3, then Phase 13) will
+// re-check some of this for the sake of a good inline error; that second copy is
+// expected to drift, so this one is the one that decides, and a definition
+// arriving by any other route gets the same answer.
+//
+// **The three rules with teeth, and each is a rule about time rather than about
+// shape:**
+//
+// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable
+// `event_versions` row and points `current_version_id` at it. Editing
+// afterwards is free and does not touch the row a live run pinned.
+// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module
+// must not make an author's work uneditable, and it must not let a version be
+// published that names a verb nobody can perform.
+// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so
+// a hard delete of a definition that has ever run is refused by the database
+// anyway — and the row's history is the thing an audit reads.
+
+const db = require('./eventDefinitions.db')
+const versionsDb = require('./eventVersions.db')
+const runsDb = require('./eventRuns.db')
+const seriesDb = require('./eventSeries.db')
+const spec = require('../../events/spec')
+const { slugify, uniqueSlug } = require('../teams/teamSlug')
+const { cleanBody } = require('../../utils/sanitizeHtml')
+
+const MAX_TITLE = 200
+const MAX_SUMMARY = 500
+const MAX_URL = 500
+const MAX_CONCURRENCY_KEY = 190
+
+// A day either side of §D's default. Below a minute the grace window cannot
+// survive a single slow boot; above a day a "missed" occurrence would start
+// silently the following afternoon, which is the exact behaviour §E forbids.
+const MIN_GRACE_SECONDS = 60
+const MAX_GRACE_SECONDS = 86_400
+
+/**
+ * IANA zone names, checked against the platform's own database rather than a
+ * list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node
+ * ships the full tzdata — so this is the same check Phase 4's occurrence
+ * arithmetic will make, asked one screen earlier where an operator can fix it.
+ */
+function isTimezone(tz) {
+ try {
+ Intl.DateTimeFormat(undefined, { timeZone: tz })
+ return true
+ } catch {
+ return false
+ }
+}
+
+const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
+
+const trimOrNull = (v, max) => {
+ if (v === undefined || v === null) return null
+ const s = String(v).trim()
+ return s === '' ? null : s.slice(0, max)
+}
+
+/**
+ * Validate an incoming definition against the registries and the schema.
+ *
+ * `{ ok: true, definition }` with a normalised row ready for insert/update, or
+ * `{ ok: false, errors }` listing every problem rather than the first.
+ *
+ * `existing` is the row being edited, or null on a create. It is what lets a
+ * dormant step survive: the ids already in the saved spec widen what the spec
+ * validator will accept, so an uninstall is never destructive after the fact.
+ */
+async function validate(input, { existing = null } = {}) {
+ const errors = []
+ const body = isPlainObject(input) ? input : {}
+
+ const title = trimOrNull(body.title, MAX_TITLE)
+ if (!title) errors.push('title is required')
+
+ const summary = trimOrNull(body.summary, MAX_SUMMARY)
+ const imageUrl = trimOrNull(body.imageUrl, MAX_URL)
+ const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY)
+
+ // The storyline. Sanitized on write, exactly as a wiki page and a forum post
+ // are: it is author-supplied HTML that ends up on a public page.
+ const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body))
+
+ const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC'
+ if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`)
+
+ const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds
+ const graceSeconds = Number(graceRaw)
+ if (
+ !Number.isInteger(graceSeconds) ||
+ graceSeconds < MIN_GRACE_SECONDS ||
+ graceSeconds > MAX_GRACE_SECONDS
+ ) {
+ errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`)
+ }
+
+ let seriesId = null
+ if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') {
+ seriesId = Number(body.seriesId)
+ if (!Number.isInteger(seriesId) || seriesId < 1) {
+ errors.push('seriesId must be an integer')
+ seriesId = null
+ } else if (!(await seriesDb.exists(seriesId))) {
+ // Checked here as well as by the foreign key, because a 1452 reaching a
+ // controller is a 500 and this is a 400 an author can act on.
+ errors.push(`seriesId ${seriesId} does not exist`)
+ seriesId = null
+ }
+ } else if (existing) {
+ seriesId = existing.series_id
+ }
+
+ const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder
+ const seriesOrder = Number(seriesOrderRaw)
+ if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
+
+ // ── the spec ──
+ const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
+ const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
+ const checked = spec.validate(rawSpec, { knownActionIds: known })
+ if (!checked.ok) errors.push(...checked.errors)
+
+ // ── the slug ──
+ //
+ // Derived from the title on create and FROZEN afterwards, like a Team's: the
+ // public event page lives at it, and a retitle must not break a link somebody
+ // posted in Discord. An author who genuinely needs a different address makes a
+ // new definition.
+ let slug = existing?.slug || null
+ if (!slug) {
+ const stem = slugify(title || '') || 'event'
+ const taken = (await db.list()).map((d) => d.slug)
+ slug = uniqueSlug(stem, taken, { fallback: 'event' })
+ }
+
+ if (errors.length) return { ok: false, errors }
+
+ return {
+ ok: true,
+ definition: {
+ title,
+ slug,
+ summary,
+ body: storyline,
+ image_url: imageUrl,
+ owner_module: existing?.owner_module ?? null,
+ series_id: seriesId,
+ series_order: seriesOrder,
+ concurrency_key: concurrencyKey,
+ grace_seconds: graceSeconds,
+ timezone,
+ spec: checked.spec,
+ },
+ }
+}
+
+/** Create a draft. */
+async function create(input, userId) {
+ const result = await validate(input)
+ if (!result.ok) return result
+ const id = await db.insert({ ...result.definition, created_by: userId })
+ return { ok: true, id, definition: await db.getById(id) }
+}
+
+/**
+ * Edit a definition.
+ *
+ * An `archived` definition is not editable. That is the one state check here,
+ * and it is a real one rather than a formality: archiving is how a definition is
+ * retired, and a retired definition that can still be edited is a definition
+ * somebody will edit and then wonder why it never runs.
+ */
+async function save(id, input, userId) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
+ if (existing.state === 'archived') {
+ return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] }
+ }
+ const result = await validate(input, { existing })
+ if (!result.ok) return result
+ await db.update(id, { ...result.definition, updated_by: userId })
+ return { ok: true, id, definition: await db.getById(id) }
+}
+
+/**
+ * Publish: snapshot the working spec into an immutable version and go `ready`.
+ *
+ * The spec is re-validated here against the registries as they stand RIGHT NOW,
+ * not trusted from the save that wrote it. A module uninstalled between the two
+ * is the whole reason: the save was legitimate, and publishing a version whose
+ * steps name a verb nobody can perform would be a run that fails at dispatch
+ * with the world half-changed.
+ */
+async function publish(id, userId) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
+ if (existing.state === 'archived') {
+ return { ok: false, status: 409, errors: ['an archived definition cannot be published'] }
+ }
+
+ const checked = spec.validate(existing.spec, {
+ knownActionIds: spec.actionIdsIn(existing.spec),
+ })
+ if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
+
+ const publishable = spec.publishable(checked.spec)
+ if (!publishable.ok) {
+ return {
+ ok: false,
+ status: 409,
+ errors: [
+ `cannot publish: no module registers ${publishable.dormant.join(', ')}`,
+ ],
+ }
+ }
+ if (!checked.spec.phases.some((p) => p.steps.length)) {
+ // An empty event publishes cleanly and then does nothing, which looks like a
+ // broken run rather than an empty one. Refusing costs an author one click and
+ // saves an operator a diagnosis.
+ return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] }
+ }
+
+ const version = await versionsDb.nextVersion(id)
+ const versionId = await versionsDb.insert(id, version, checked.spec, userId)
+ await db.markReady(id, versionId, userId)
+ return { ok: true, versionId, version, definition: await db.getById(id) }
+}
+
+/**
+ * Archive.
+ *
+ * Refused while a run of this definition is still in flight — not because the
+ * database would object (it would not; archiving is an UPDATE), but because the
+ * screen the run is on reads its title and state from here, and retiring a
+ * definition mid-run makes the console describe something that is no longer
+ * supposed to exist. Cancel the run, then archive.
+ */
+async function archive(id, userId) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
+ if (existing.state === 'archived') return { ok: true, definition: existing }
+
+ const active = await runsDb.countActiveForDefinition(id)
+ if (active > 0) {
+ return {
+ ok: false,
+ status: 409,
+ errors: [`cannot archive: ${active} run(s) of this definition are still in flight`],
+ }
+ }
+ await db.archive(id, userId)
+ return { ok: true, definition: await db.getById(id) }
+}
+
+module.exports = {
+ validate,
+ create,
+ save,
+ publish,
+ archive,
+ isTimezone,
+ MIN_GRACE_SECONDS,
+ MAX_GRACE_SECONDS,
+}
diff --git a/server/src/model/events/eventJson.js b/server/src/model/events/eventJson.js
new file mode 100644
index 0000000..56cebbf
--- /dev/null
+++ b/server/src/model/events/eventJson.js
@@ -0,0 +1,23 @@
+// ── One JSON reader for the whole events model ─────────────────────────────
+//
+// JSON columns come back from the driver already parsed on some MariaDB/driver
+// combinations and as a string on others — it depends on whether the column is a
+// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as. Every
+// read in this directory goes through this, so no caller has to know which it
+// got.
+//
+// Lifted from `engagementRules.db.js`, which learned it first, and hoisted into
+// its own file here rather than copied into six: six copies of a fallback is six
+// chances for one of them to fall back to `{}` where the reader expects `[]`.
+
+function parseJson(value, fallback) {
+ if (value === null || value === undefined) return fallback
+ if (typeof value !== 'string') return value
+ try {
+ return JSON.parse(value)
+ } catch {
+ return fallback
+ }
+}
+
+module.exports = { parseJson }
diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js
new file mode 100644
index 0000000..a32732c
--- /dev/null
+++ b/server/src/model/events/eventRunLog.db.js
@@ -0,0 +1,67 @@
+// ── event_run_log — SQL only ───────────────────────────────────────────────
+//
+// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and
+// `activity_log.detail` is TEXT and unqueryable, which is why this table exists
+// beside the audit log rather than instead of it. Both are written: the audit of
+// WHO published WHAT goes to `activity_log`, the diagnosis goes here.
+//
+// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The
+// set grows with almost every later phase — conditions in Phase 5, cap draws in
+// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter
+// this project has no migration system for. A constant in a file is the same
+// guarantee with a cheaper hinge.
+
+const log = require('../../utils/logger')('events')
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a
+// stored row would then name a kind no reader knows.
+const KINDS = [
+ 'run.created', // an occurrence was materialised
+ 'run.status', // a status transition, with from/to
+ 'phase.entered', // a phase's steps were materialised
+ 'step.status', // a step transition, with the module's answer
+ 'note', // a human action taken from the admin surface
+]
+
+const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
+
+const listForRun = async (runId, { limit = 500 } = {}) => {
+ const n = Math.min(Math.max(Number(limit) || 500, 1), 2000)
+ return (
+ await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [
+ runId,
+ ])
+ ).map(hydrate)
+}
+
+/**
+ * Write one line. **Never throws.**
+ *
+ * The diagnostic log is what an operator reads when something has already gone
+ * wrong, so a failure to write it must not become a second failure on top of the
+ * first — a runner that aborted a run because it could not record why would be
+ * the worst possible reading of "observability". The same posture
+ * `uoLinkClient.js` takes: answer, do not throw.
+ */
+async function write({ runId, stepId = null, kind, phase = null, detail = null }) {
+ if (!KINDS.includes(kind)) {
+ // A programming error, not an operational one, and it is louder than a
+ // silent drop for exactly that reason.
+ log.warn('event run log: unknown kind', { kind, runId })
+ return false
+ }
+ try {
+ await query(
+ 'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)',
+ [runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)],
+ )
+ return true
+ } catch (err) {
+ log.error('event run log write failed', { runId, kind, message: err.message })
+ return false
+ }
+}
+
+module.exports = { KINDS, listForRun, write }
diff --git a/server/src/model/events/eventRunSteps.db.js b/server/src/model/events/eventRunSteps.db.js
new file mode 100644
index 0000000..bf0a7dc
--- /dev/null
+++ b/server/src/model/events/eventRunSteps.db.js
@@ -0,0 +1,95 @@
+// ── event_run_steps — SQL only ─────────────────────────────────────────────
+//
+// EVENTS.md §D and §E. Phase 1 materialises a run's steps and reads them back
+// for the run console. **Draining them is Phase 2's**: the CAS claim, the lease,
+// the attempt counter and the classification of a module's answer are the
+// runner, and none of them is stubbed here.
+//
+// The one runtime property Phase 1 does have to get right is the idempotency key
+// (§E). Core mints it 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. That
+// makes it a property of the INSERT below rather than of the dispatch, which is
+// the only reason it can be stable at all.
+
+const crypto = require('crypto')
+
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+const hydrate = (row) => row && { ...row, params: parseJson(row.params, {}) }
+
+/**
+ * `sha256(runId | stepId)`, truncated to 40 hex — the shape `shardEvents.dedupeKey`
+ * already uses, so the two dedupe keys on this codebase read alike.
+ *
+ * The step id is not known until the row exists, so materialisation inserts with
+ * a provisional key and stamps the real one immediately afterwards. That is one
+ * extra statement per step and it buys the property the whole retry story rests
+ * on: the key is a function of identity, never of attempt or of clock.
+ */
+const idempotencyKey = (runId, stepId) =>
+ crypto.createHash('sha256').update(`${runId}|${stepId}`).digest('hex').slice(0, 40)
+
+const listForRun = async (runId) =>
+ (
+ await query(
+ 'SELECT * FROM event_run_steps WHERE run_id = ? ORDER BY phase, seq, id',
+ [runId],
+ )
+ ).map(hydrate)
+
+const getById = async (id) => {
+ const [row] = await query('SELECT * FROM event_run_steps WHERE id = ?', [id])
+ return hydrate(row)
+}
+
+/**
+ * Materialise one phase's steps.
+ *
+ * `INSERT IGNORE` against `UNIQUE (run_id, phase, seq)`, so a tick that overran
+ * into the next one cannot double-materialise a phase — the same argument the
+ * occurrence key makes one table up, at the other end of the run.
+ *
+ * Returns the rows as they now stand, created or pre-existing, so a caller that
+ * lost the race still gets the step ids.
+ */
+const materialisePhase = async (runId, phase, steps) => {
+ for (let i = 0; i < steps.length; i++) {
+ const step = steps[i]
+ const result = await query(
+ `INSERT IGNORE INTO event_run_steps
+ (run_id, phase, seq, action_id, params, action_version, on_failure, idempotency_key)
+ VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
+ [
+ runId,
+ phase,
+ i,
+ step.actionId,
+ JSON.stringify(step.params || {}),
+ step.actionVersion || 1,
+ step.onFailure || 'pause',
+ ],
+ )
+ if (Number(result?.affectedRows || 0) === 1) {
+ // Stamped in a second statement because the key is a function of the row's
+ // own id. Scoped by the empty key so a re-run of this loop over an existing
+ // phase can never overwrite a key a dispatch has already sent.
+ await query(
+ "UPDATE event_run_steps SET idempotency_key = ? WHERE id = ? AND idempotency_key = ''",
+ [idempotencyKey(runId, result.insertId), result.insertId],
+ )
+ }
+ }
+ return listForRun(runId)
+}
+
+/** The run console's summary line: how many steps sit in each status. */
+const statusCounts = async (runId) => {
+ const rows = await query(
+ 'SELECT status, COUNT(*) AS n FROM event_run_steps WHERE run_id = ? GROUP BY status',
+ [runId],
+ )
+ return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]))
+}
+
+module.exports = { listForRun, getById, materialisePhase, statusCounts, idempotencyKey }
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
new file mode 100644
index 0000000..bf8a59f
--- /dev/null
+++ b/server/src/model/events/eventRuns.db.js
@@ -0,0 +1,105 @@
+// ── event_runs — SQL only ──────────────────────────────────────────────────
+//
+// EVENTS.md §D and §E. Phase 1 writes exactly one kind of row — a `scheduled`
+// occurrence — and reads them back for the admin surface. **The claim, the CAS
+// transitions and the lease reclaim are Phase 2's** and are deliberately not
+// stubbed here: a half-written claim is worse than no claim, because it reads as
+// protection.
+//
+// What Phase 1 does own is the INSERT, and it owns the important half of it:
+// materialisation is `INSERT IGNORE` against `UNIQUE (definition_id, scope,
+// scheduled_for)`, so a second attempt at one occurrence writes nothing and
+// answers honestly rather than raising a duplicate-key error a caller has to
+// interpret.
+
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
+
+const SELECT_LIST = `
+ SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number
+ FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ JOIN event_versions v ON v.id = r.version_id
+`
+
+/**
+ * The admin run list. Newest occurrence first, across every definition.
+ *
+ * `limit` is interpolated after an integer coercion rather than bound, because
+ * MariaDB will not take a placeholder in LIMIT on a prepared statement. It never
+ * reaches SQL as anything but a number.
+ */
+const list = async ({ definitionId = null, status = null, limit = 100 } = {}) => {
+ const where = []
+ const args = []
+ if (definitionId) {
+ where.push('r.definition_id = ?')
+ args.push(definitionId)
+ }
+ if (status) {
+ where.push('r.status = ?')
+ args.push(status)
+ }
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
+ const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
+ const rows = await query(
+ `${SELECT_LIST} ${clause} ORDER BY r.scheduled_for DESC, r.id DESC LIMIT ${n}`,
+ args,
+ )
+ return rows.map(hydrate)
+}
+
+const getById = async (id) => {
+ const [row] = await query(`${SELECT_LIST} WHERE r.id = ?`, [id])
+ return hydrate(row)
+}
+
+/**
+ * Materialise one occurrence. Answers the row id, or `null` when one already
+ * existed — which is not an error and is the ordinary answer under a tick that
+ * overran into the next one.
+ */
+const materialise = async (run) => {
+ const result = await query(
+ `INSERT IGNORE INTO event_runs
+ (definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
+ params, rehearsal, started_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ run.definition_id,
+ run.version_id,
+ run.scope || '',
+ run.scheduled_for,
+ run.timezone || 'UTC',
+ run.concurrency_key,
+ run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
+ run.rehearsal ? 1 : 0,
+ run.started_by,
+ ],
+ )
+ return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
+}
+
+/** The occurrence the unique key names, whether or not this call created it. */
+const findOccurrence = async (definitionId, scope, scheduledFor) => {
+ const [row] = await query(
+ `${SELECT_LIST} WHERE r.definition_id = ? AND r.scope = ? AND r.scheduled_for = ?`,
+ [definitionId, scope || '', scheduledFor],
+ )
+ return hydrate(row)
+}
+
+/** Is anything of this definition not yet terminal? The archive pre-check. */
+const countActiveForDefinition = async (definitionId) => {
+ const [row] = await query(
+ `SELECT COUNT(*) AS n FROM event_runs
+ WHERE definition_id = ?
+ AND status IN ('scheduled','starting','running','paused','ending')`,
+ [definitionId],
+ )
+ return Number(row?.n || 0)
+}
+
+module.exports = { list, getById, materialise, findOccurrence, countActiveForDefinition }
diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js
new file mode 100644
index 0000000..34294c5
--- /dev/null
+++ b/server/src/model/events/eventRuns.model.js
@@ -0,0 +1,134 @@
+// ── Event runs — creating an occurrence ────────────────────────────────────
+//
+// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's
+// steps. **It does not start anything**: there is no runner until Phase 2, so a
+// row created here sits at `scheduled` indefinitely. That is the correct
+// behaviour for this phase and it has to be VISIBLE as such rather than looking
+// broken, which is why `create()` answers with the row and the admin surface
+// renders the status verbatim.
+//
+// Two properties this file owns, both of which are the reason it exists before
+// the runner rather than with it:
+//
+// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two
+// attempts at one occurrence produce one row and an honest answer, not a
+// duplicate-key error a caller has to interpret. The unique index — not a
+// claim — is what makes "one run per occurrence per scope" true (§E).
+// - **The idempotency key is minted with the step row and never varies by
+// attempt.** It is a function of identity, so it can only be stable if it is
+// stamped where the identity is created.
+
+const db = require('./eventRuns.db')
+const stepsDb = require('./eventRunSteps.db')
+const logDb = require('./eventRunLog.db')
+const definitionsDb = require('./eventDefinitions.db')
+const versionsDb = require('./eventVersions.db')
+
+const MAX_SCOPE = 190
+
+/**
+ * Render a definition's `concurrency_key` template against a run's params.
+ *
+ * `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A
+ * placeholder with no matching param is left standing rather than replaced with
+ * an empty string: `invasion:` would collide with every other unrendered key on
+ * the deployment, which is the opposite of what a concurrency key is for, and a
+ * literal `invasion:{region}` in the column is a visible mistake.
+ */
+function renderConcurrencyKey(template, params) {
+ if (!template) return null
+ return String(template)
+ .replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => {
+ const value = params && params[name]
+ return value === undefined || value === null || value === '' ? whole : String(value)
+ })
+ .slice(0, MAX_SCOPE)
+}
+
+/**
+ * Create one occurrence of a definition and materialise its first phase.
+ *
+ * `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
+ * the present, not a separate concept, which is what keeps the runner's one
+ * materialise/advance path honest when Phase 4 adds recurrence on top.
+ */
+async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) {
+ const definition = await definitionsDb.getById(definitionId)
+ if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
+ if (definition.state !== 'ready') {
+ return {
+ ok: false,
+ status: 409,
+ errors: [`a ${definition.state} definition has no published version to run`],
+ }
+ }
+ if (!definition.current_version_id) {
+ return { ok: false, status: 409, errors: ['this definition has no published version'] }
+ }
+
+ const version = await versionsDb.getById(definition.current_version_id)
+ if (!version?.spec?.phases?.length) {
+ return { ok: false, status: 409, errors: ['the published version has no phases'] }
+ }
+
+ const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
+ const when = scheduledFor ? new Date(scheduledFor) : new Date()
+ if (Number.isNaN(when.getTime())) {
+ return { ok: false, status: 400, errors: ['scheduledFor is not a date'] }
+ }
+
+ const runId = await db.materialise({
+ definition_id: definitionId,
+ version_id: version.id,
+ scope: scopeValue,
+ // Stored as UTC. The definition's zone is what an occurrence is COMPUTED in
+ // (Phase 4); what is stored is the instant.
+ scheduled_for: when,
+ timezone: definition.timezone,
+ concurrency_key: renderConcurrencyKey(definition.concurrency_key, params),
+ params,
+ rehearsal,
+ started_by: userId,
+ })
+
+ if (runId === null) {
+ // The occurrence already existed. Not an error — it is what the unique index
+ // is for — so the existing row is the answer.
+ const existing = await db.findOccurrence(definitionId, scopeValue, when)
+ return { ok: true, created: false, run: existing }
+ }
+
+ await logDb.write({
+ runId,
+ kind: 'run.created',
+ detail: {
+ definitionId,
+ versionId: version.id,
+ version: version.version,
+ scope: scopeValue,
+ rehearsal: Boolean(rehearsal),
+ by: userId,
+ },
+ })
+
+ // The first phase's steps, materialised at creation rather than at start.
+ // Phase 2 materialises each LATER phase as the run enters it; doing the first
+ // one here is what makes a Phase 1 run row inspectable — an operator can see
+ // the steps that would run, with their params and their idempotency keys,
+ // before there is anything to run them.
+ const first = version.spec.phases[0]
+ await stepsDb.materialisePhase(runId, first.key, first.steps || [])
+ await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } })
+
+ return { ok: true, created: true, run: await db.getById(runId) }
+}
+
+/** A run, its steps and its status counts — what the run console reads. */
+async function detail(runId) {
+ const run = await db.getById(runId)
+ if (!run) return null
+ const [steps, counts] = await Promise.all([stepsDb.listForRun(runId), stepsDb.statusCounts(runId)])
+ return { run, steps, counts }
+}
+
+module.exports = { create, detail, renderConcurrencyKey }
diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js
new file mode 100644
index 0000000..2ed3729
--- /dev/null
+++ b/server/src/model/events/eventSeries.db.js
@@ -0,0 +1,23 @@
+// ── event_series — SQL only ────────────────────────────────────────────────
+//
+// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads —
+// `event_definitions.series_id` is a foreign key and the definition save path
+// has to check it resolves — and creating one is Phase 4's, where the calendar
+// is what makes an arc visible.
+
+const { query } = require('../../utils/db')
+
+const list = async () =>
+ query('SELECT * FROM event_series ORDER BY ordering, name, id')
+
+const getById = async (id) => {
+ const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id])
+ return row || null
+}
+
+const exists = async (id) => {
+ const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
+ return Boolean(row)
+}
+
+module.exports = { list, getById, exists }
diff --git a/server/src/model/events/eventVersions.db.js b/server/src/model/events/eventVersions.db.js
new file mode 100644
index 0000000..c77c207
--- /dev/null
+++ b/server/src/model/events/eventVersions.db.js
@@ -0,0 +1,54 @@
+// ── event_versions — SQL only ──────────────────────────────────────────────
+//
+// EVENTS.md §D. Immutable: there is an insert and there are reads, and there is
+// deliberately no update and no delete. A run pins a version, and that pin is
+// what makes the run reproducible and an audit answerable after the definition
+// has been edited underneath it.
+
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+const hydrate = (row) => row && { ...row, spec: parseJson(row.spec, null) }
+
+const listForDefinition = async (definitionId) =>
+ (
+ await query(
+ `SELECT v.id, v.definition_id, v.version, v.published_at, v.published_by, u.username AS published_by_username
+ FROM event_versions v
+ LEFT JOIN users u ON u.id = v.published_by
+ WHERE v.definition_id = ?
+ ORDER BY v.version DESC`,
+ [definitionId],
+ )
+ ).map((row) => row)
+
+const getById = async (id) => {
+ const [row] = await query('SELECT * FROM event_versions WHERE id = ?', [id])
+ return hydrate(row)
+}
+
+/**
+ * The next version number for a definition.
+ *
+ * Read separately and then INSERTed, which is a read-then-write — and it is safe
+ * only because `UNIQUE (definition_id, version)` is behind it. Two publishes
+ * racing for version 4 is one 1062 the caller reports, not two rows called 4.
+ * The unique index is the mechanism; this query is the ergonomics.
+ */
+const nextVersion = async (definitionId) => {
+ const [row] = await query(
+ 'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM event_versions WHERE definition_id = ?',
+ [definitionId],
+ )
+ return Number(row?.next || 1)
+}
+
+const insert = async (definitionId, version, spec, userId) => {
+ const result = await query(
+ 'INSERT INTO event_versions (definition_id, version, spec, published_by) VALUES (?, ?, ?, ?)',
+ [definitionId, version, JSON.stringify(spec), userId],
+ )
+ return result.insertId
+}
+
+module.exports = { listForDefinition, getById, nextVersion, insert }
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
index 2831359..950014e 100644
--- a/server/src/modules/registries.js
+++ b/server/src/modules/registries.js
@@ -124,6 +124,20 @@ const triggers = new Map()
// trigger of the same name would be a collision between two unrelated things.
const audiences = new Map()
+// action id → { owner, id, label, description, risk, reversible, version,
+// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1).
+//
+// **Its own id space**, like `audiences` above and for the same kind of reason:
+// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as
+// the thing a module can be asked to do and `uo.champ.start` as the thing that
+// happened are two unrelated declarations that must not collide with — or
+// silently satisfy — each other. Nothing cross-checks this map against the
+// stream/trigger namespace, and nothing should.
+//
+// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion
+// order is what the admin catalog renders in.
+const eventActions = new Map()
+
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
@@ -163,6 +177,10 @@ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
// Audiences are their own id space (see the `audiences` Map), so they get their
// own constant even though the grammar is the same one.
const AUDIENCE_ID = EVENT_ID
+// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id
+// spaces"). One grammar, three namespaces — the constant is what makes the
+// namespace visible at every use site.
+const ACTION_ID = EVENT_ID
// A module's claim must carry its id. Core's ids are its own namespace, and the
// grandfathered names are the ones that predate all of this.
@@ -367,6 +385,33 @@ async function resolveAudience(id, params = {}) {
}
}
+// ── Event actions (EVENTS.md §F) ───────────────────────────────────────────
+
+/**
+ * Every declaration WITHOUT its callables — what the admin catalog serves.
+ *
+ * `perform`, `revert` and `cost` are stripped for the same reason `resolve` is
+ * stripped from an audience and `handler` from a slash command: this is the
+ * object that leaves the process, and the browser's whole relationship with an
+ * action is naming one by id. §F's "a module registers actions server-side and
+ * adds no routes for them" is only true if the functions never ride out.
+ */
+const allEventActions = () =>
+ [...eventActions.values()].map(({ perform, revert, cost, ...rest }) => rest)
+
+/** One declaration, callables included. The runner's lookup (Phase 2). */
+const eventAction = (id) => eventActions.get(id) || null
+
+/**
+ * Does anyone register this id right now?
+ *
+ * The authoring path's question, and it is deliberately not `eventAction(id) !==
+ * null` at every call site: a step naming an action whose module is uninstalled
+ * is DORMANT, not an error (§F), and the difference between "never existed" and
+ * "not installed today" is a distinction only the caller can draw.
+ */
+const isEventAction = (id) => eventActions.has(id)
+
// ── Shape checks, run the moment a registrant calls ────────────────────────
//
// Split from the collision checks below on the same line PR 3 drew through
@@ -745,6 +790,170 @@ function checkAudienceShape(entry) {
}
}
+// ── Event action shape (EVENTS.md §F) ──────────────────────────────────────
+
+// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and
+// "world-write", which are game words a chess ladder has no use for — and
+// deliberately not extensible by a module, because the class is what core
+// derives a step's `on_failure` from (§L) and a module that could invent
+// `harmless` would be choosing its own retry policy.
+const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible']
+
+// What core must know in order to clean up after a run (§L). `none` is gone once
+// done; `self` undoes itself; `ledger` needs a `revert` over the rows core
+// recorded; `override` is a lease, whose baseline core restores.
+const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override']
+
+// The same six types a trigger variable uses. One vocabulary over both, because
+// the authoring form that renders an action param and the template editor that
+// renders a trigger variable are the same widget over the same six types, and a
+// second list is a list that drifts.
+const ACTION_PARAM_TYPES = VARIABLE_TYPES
+
+// The param name grammar, shared with trigger variables for the same reason: a
+// param ends up as a key in a JSON object an operator reads.
+const PARAM_NAME = VARIABLE_NAME
+
+// The default per-invocation deadline. Ten seconds is §F's own figure and it is
+// the number the sidecar's own request timeout is set near — long enough for a
+// round trip through a module, a sidecar and a game tick, short enough that a
+// wedged action does not hold a step's claim past its lease.
+const DEFAULT_BUDGET_MS = 10_000
+// An hour. Not "unlimited by another name": the bound exists so that a typo in a
+// declaration is a slow action rather than a step that never times out at all,
+// and Phase 2's lease has to be longer than this to mean anything.
+const MAX_BUDGET_MS = 3_600_000
+
+function checkActionParam(actionId, entry, seen) {
+ const { name, type, required, example, description, source } = entry || {}
+ const where = `registerEventActions: ${actionId}`
+ if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
+ if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
+ seen.add(name)
+ if (!ACTION_PARAM_TYPES.includes(type)) {
+ throw new Error(`${where}: param "${name}" has unsupported type "${type}"`)
+ }
+ // REQUIRED, on every param including the optional ones, and it is the same
+ // argument `checkTriggerVariable` makes: without it the authoring form has no
+ // placeholder and the operator is typing into a blank box, which is exactly
+ // how an unattended world write comes to be scheduled with a typo in it. It is
+ // one word at declaration time and unreconstructable afterwards.
+ if (example === undefined || example === null || example === '') {
+ throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`)
+ }
+ // A `source` names a module-served option endpoint, so the field is a dropdown
+ // of real values rather than a text box (§F "Param option sources"). It is
+ // checked as an id here and resolved nowhere yet — the endpoint that answers it
+ // is Phase 7's, and a `source` naming nothing degrades the field to free text
+ // with a warning rather than blocking the form.
+ if (source !== undefined && !ACTION_ID.test(source || '')) {
+ throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
+ }
+ return {
+ name,
+ type,
+ required: Boolean(required),
+ example,
+ source: source === undefined ? null : source,
+ description: description || '',
+ }
+}
+
+/**
+ * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`.
+ *
+ * A typed verb core may ask a registrant to carry out. Everything decidable from
+ * the argument alone is decided here, at the call; the collision — is this id
+ * already someone's action? — waits for `apply()`.
+ *
+ * The copy at the end is explicit rather than a spread, like every other shape
+ * check in this file: this object is served to the admin catalog and is what a
+ * step's params are validated against, so anything not named here is not part of
+ * the contract and must not ride along.
+ *
+ * **Nothing here executes and nothing here may touch the database.** Registration
+ * runs under `routeManifest.js` and `swagger.js` against a dead pool
+ * (MODULE_API.md §2.2), and core's own registration is subject to the same rule
+ * as a module's.
+ */
+function checkEventActionShape(entry) {
+ const a = entry || {}
+ if (!ACTION_ID.test(a.id || '')) {
+ throw new Error(`registerEventActions: bad action id "${a.id}"`)
+ }
+ if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`)
+
+ // Both required with no default, for the reason a trigger's ceiling is: there
+ // is no safe value to guess. Defaulting `risk` to `notify` would give a world
+ // write the retry policy of a broadcast, and defaulting `reversible` to `none`
+ // would tell the cleanup generator there is nothing to undo.
+ if (!ACTION_RISKS.includes(a.risk)) {
+ throw new Error(
+ `registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`,
+ )
+ }
+ if (!ACTION_REVERSIBLE.includes(a.reversible)) {
+ throw new Error(
+ `registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`,
+ )
+ }
+
+ if (typeof a.perform !== 'function') {
+ throw new Error(`registerEventActions: ${a.id} has no perform()`)
+ }
+ // §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather
+ // than discovered at teardown, because the moment it matters is the moment a
+ // run has already created something and the answer "there is no undo" is the
+ // one answer cleanup cannot act on.
+ if (a.reversible === 'ledger' && typeof a.revert !== 'function') {
+ throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`)
+ }
+ // The mirror check, and it is not pedantry: a `revert` on a `reversible:
+ // 'none'` action is a module author who believes their action can be undone
+ // and a cleanup generator that will never call it. Silence there is a promise
+ // core does not keep.
+ if (a.revert !== undefined && a.reversible !== 'ledger') {
+ throw new Error(
+ `registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
+ )
+ }
+ if (a.cost !== undefined && typeof a.cost !== 'function') {
+ throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
+ }
+
+ const version = a.version === undefined ? 1 : a.version
+ if (!Number.isInteger(version) || version < 1) {
+ throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`)
+ }
+
+ const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs
+ if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) {
+ throw new Error(
+ `registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`,
+ )
+ }
+
+ if (a.params !== undefined && !Array.isArray(a.params)) {
+ throw new Error(`registerEventActions: ${a.id} params must be an array`)
+ }
+ const seen = new Set()
+ const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen))
+
+ return {
+ id: a.id,
+ label: a.label,
+ description: a.description || '',
+ risk: a.risk,
+ reversible: a.reversible,
+ version,
+ budgetMs,
+ params,
+ cost: a.cost || null,
+ perform: a.perform,
+ revert: a.revert || null,
+ }
+}
+
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
// **Two mechanisms, and the asymmetry between them is the whole design.**
@@ -983,6 +1192,7 @@ function stage(owner) {
slashCommands: [],
triggers: [],
audiences: [],
+ eventActions: [],
engagementSeeds: [],
}
return {
@@ -1015,6 +1225,16 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
},
+ // EVENTS.md §F, Phase 1. Present on the staging area from this phase and
+ // reached ONLY by `registerCore()` below — `loader.js` builds its own `api`
+ // facade and has no method that delegates here, so a module cannot call this
+ // yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then
+ // the seam is exercised on every boot by core's own three actions and by
+ // nothing else, which is the point of registering them through it.
+ registerEventActions(entries) {
+ if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
+ for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
+ },
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
@@ -1040,6 +1260,7 @@ function apply({
slashCommands: newSlashCommands = [],
triggers: newTriggers = [],
audiences: newAudiences = [],
+ eventActions: newEventActions = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
@@ -1096,6 +1317,23 @@ function apply({
seenAudiences.add(a.id)
}
+ // Actions, against their OWN map and nothing else. No cross-facet check with
+ // streams or triggers: an action id and a trigger id are different namespaces
+ // (§F), so `uo.champ.start` may legitimately be both a verb and an event, and
+ // reading a collision there would forbid the most natural pair of names a
+ // module will ever want. No legacy allowlist either — nothing predates this,
+ // so the prefix rule has no exceptions and should never grow one.
+ const seenActions = new Set()
+ for (const a of newEventActions) {
+ const held = eventActions.get(a.id)
+ if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`)
+ if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`)
+ if (!namespaced(owner, a.id, {})) {
+ throw new Error(`event action "${a.id}" is not namespaced "${owner}."`)
+ }
+ seenActions.add(a.id)
+ }
+
const seenLegs = new Set()
for (const l of newLegs) {
const held = legs.get(l.leg)
@@ -1160,6 +1398,7 @@ function apply({
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
+ for (const a of newEventActions) eventActions.set(a.id, { owner, ...a })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
}
@@ -1182,6 +1421,7 @@ function registerCore() {
/* eslint-disable global-require */
const coreStreams = require('../config/coreStreams')
const coreTriggers = require('../config/coreTriggers')
+ const coreEventActions = require('../config/coreEventActions')
const discordLeg = require('../utils/discordAnnounce')
/* eslint-enable global-require */
@@ -1192,6 +1432,11 @@ function registerCore() {
// its five stream ids — the same-owner upgrade the one-namespace rule above is
// written for — so this batch exercises the cross-facet check on every boot.
api.registerEventTriggers(coreTriggers.TRIGGERS)
+ // The event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`,
+ // `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a
+ // module, so the registry is exercised on every boot long before a module uses
+ // it — the argument registerCore() has made since the module system's Phase 3.
+ api.registerEventActions(coreEventActions.ACTIONS)
// The three lines that used to follow — the shard stream catalog, the town
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
@@ -1205,6 +1450,7 @@ function registerCore() {
log.info('core registrations complete', {
streams: streams.length,
eventTriggers: triggers.size,
+ eventActions: eventActions.size,
announceLegs: legs.size,
extensions: [...slots.keys()].filter(slotFilledBy),
})
@@ -1236,6 +1482,7 @@ function _reset() {
slashCommands.clear()
triggers.clear()
audiences.clear()
+ eventActions.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -1264,11 +1511,18 @@ module.exports = {
allAudiences,
audience,
resolveAudience,
+ allEventActions,
+ eventAction,
+ isEventAction,
allEngagementSeeds,
engagementSeedsFor,
SEEDABLE_CHANNELS,
VARIABLE_TYPES,
TRIGGER_KINDS,
+ ACTION_RISKS,
+ ACTION_REVERSIBLE,
+ ACTION_PARAM_TYPES,
+ DEFAULT_BUDGET_MS,
stage,
apply,
registerCore,
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
new file mode 100644
index 0000000..7080dee
--- /dev/null
+++ b/server/src/router/v1/admin/events.controller.js
@@ -0,0 +1,315 @@
+// ── Admin: events ──────────────────────────────────────────────────────────
+//
+// EVENTS.md § API surface, Phase 1. Definitions CRUD, publish, archive, the
+// action catalog and the run reads.
+//
+// This file reads ids out of URLs and shapes responses; it validates nothing.
+// Every decision lives in `model/events/*.model.js` and in `events/spec.js`, so
+// a definition arriving from a future import or a restore gets the same answer
+// this screen does.
+//
+// **What is deliberately absent**: pause, resume, advance, cancel, step
+// skip/retry/confirm, cleanup and the action switchboard. Each of them acts on a
+// run in flight, and nothing is in flight until Phase 2 builds the runner. A
+// control that returns 200 and does nothing is worse than one that is not there.
+
+const registries = require('../../../modules/registries')
+const spec = require('../../../events/spec')
+const definitionsDb = require('../../../model/events/eventDefinitions.db')
+const definitions = require('../../../model/events/eventDefinitions.model')
+const versionsDb = require('../../../model/events/eventVersions.db')
+const seriesDb = require('../../../model/events/eventSeries.db')
+const runsDb = require('../../../model/events/eventRuns.db')
+const runs = require('../../../model/events/eventRuns.model')
+const logDb = require('../../../model/events/eventRunLog.db')
+const activity = require('../../../model/activity/activity.model')
+
+const asId = (raw) => {
+ const n = Number(raw)
+ return Number.isInteger(n) && n > 0 ? n : null
+}
+
+/**
+ * The shape a definition takes on the wire.
+ *
+ * Explicit rather than the row, like every other admin surface here: the row
+ * carries `created_by`, `updated_by` and the joined series columns, and a
+ * response that spreads it is a response that gains a column the day somebody
+ * adds one.
+ */
+const shapeDefinition = (d) => ({
+ id: d.id,
+ title: d.title,
+ slug: d.slug,
+ summary: d.summary,
+ body: d.body,
+ imageUrl: d.image_url,
+ ownerModule: d.owner_module,
+ state: d.state,
+ currentVersionId: d.current_version_id,
+ currentVersion: d.current_version,
+ seriesId: d.series_id,
+ seriesName: d.series_name,
+ seriesOrder: d.series_order,
+ concurrencyKey: d.concurrency_key,
+ graceSeconds: d.grace_seconds,
+ timezone: d.timezone,
+ spec: d.spec,
+ createdAt: d.created_at,
+ updatedAt: d.updated_at,
+})
+
+const shapeRun = (r) => ({
+ id: r.id,
+ definitionId: r.definition_id,
+ definitionTitle: r.definition_title,
+ definitionSlug: r.definition_slug,
+ versionId: r.version_id,
+ version: r.version_number,
+ scope: r.scope,
+ status: r.status,
+ health: r.health,
+ cleanupStatus: r.cleanup_status,
+ currentPhase: r.current_phase,
+ scheduledFor: r.scheduled_for,
+ timezone: r.timezone,
+ concurrencyKey: r.concurrency_key,
+ params: r.params,
+ rehearsal: r.rehearsal,
+ startedAt: r.started_at,
+ endedAt: r.ended_at,
+ lastError: r.last_error,
+ createdAt: r.created_at,
+})
+
+const shapeStep = (s) => ({
+ id: s.id,
+ runId: s.run_id,
+ phase: s.phase,
+ seq: s.seq,
+ actionId: s.action_id,
+ params: s.params,
+ actionVersion: s.action_version,
+ status: s.status,
+ dueAt: s.due_at,
+ attempts: s.attempts,
+ onFailure: s.on_failure,
+ idempotencyKey: s.idempotency_key,
+ lastError: s.last_error,
+ startedAt: s.started_at,
+ finishedAt: s.finished_at,
+})
+
+/** GET /api/v1/admin/events */
+exports.list = async (req, res) => {
+ const state = ['draft', 'ready', 'archived'].includes(req.query.state) ? req.query.state : null
+ const rows = await definitionsDb.list({ state })
+ res.json({ events: rows.map(shapeDefinition) })
+}
+
+/**
+ * GET /api/v1/admin/events/catalog
+ *
+ * The registered actions, their param schemas, their risk classes and the
+ * vocabularies over both — served from the registries, so there is no table
+ * behind it and a module that was uninstalled simply stops appearing. Same
+ * argument the engagement trigger catalog makes: the editor offers exactly the
+ * set the save path checks against, so the two cannot drift.
+ *
+ * Budget dimensions are absent, and that is Phase 1 being honest rather than an
+ * omission: `registerEventBudgets` is Phase 7's and nothing declares one yet.
+ */
+exports.catalog = (_req, res) => {
+ res.json({
+ actions: registries.allEventActions(),
+ risks: registries.ACTION_RISKS,
+ reversible: registries.ACTION_REVERSIBLE,
+ paramTypes: registries.ACTION_PARAM_TYPES,
+ onFailure: spec.ON_FAILURE,
+ onFailureByRisk: spec.ON_FAILURE_BY_RISK,
+ scheduleKinds: spec.SCHEDULE_KINDS,
+ limits: {
+ maxPhases: spec.MAX_PHASES,
+ maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
+ maxSteps: spec.MAX_STEPS,
+ defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
+ },
+ })
+}
+
+/** GET /api/v1/admin/events/series */
+exports.listSeries = async (_req, res) => {
+ const rows = await seriesDb.list()
+ res.json({
+ series: rows.map((s) => ({
+ id: s.id,
+ name: s.name,
+ slug: s.slug,
+ description: s.description,
+ ordering: s.ordering,
+ })),
+ })
+}
+
+/** GET /api/v1/admin/events/runs */
+exports.listRuns = async (req, res) => {
+ const rows = await runsDb.list({
+ definitionId: asId(req.query.definitionId),
+ status: req.query.status || null,
+ limit: req.query.limit,
+ })
+ res.json({ runs: rows.map(shapeRun) })
+}
+
+/** GET /api/v1/admin/events/runs/:runId */
+exports.getRun = async (req, res) => {
+ const runId = asId(req.params.runId)
+ if (!runId) return res.status(400).json({ error: 'bad run id' })
+ const found = await runs.detail(runId)
+ if (!found) return res.status(404).json({ error: 'no such run' })
+ return res.json({
+ run: shapeRun(found.run),
+ steps: found.steps.map(shapeStep),
+ counts: found.counts,
+ })
+}
+
+/** GET /api/v1/admin/events/runs/:runId/log */
+exports.getRunLog = async (req, res) => {
+ const runId = asId(req.params.runId)
+ if (!runId) return res.status(400).json({ error: 'bad run id' })
+ const run = await runsDb.getById(runId)
+ if (!run) return res.status(404).json({ error: 'no such run' })
+ const lines = await logDb.listForRun(runId, { limit: req.query.limit })
+ return res.json({
+ log: lines.map((l) => ({
+ id: l.id,
+ stepId: l.step_id,
+ kind: l.kind,
+ phase: l.phase,
+ detail: l.detail,
+ at: l.at,
+ })),
+ kinds: logDb.KINDS,
+ })
+}
+
+/** GET /api/v1/admin/events/:id */
+exports.get = async (req, res) => {
+ const id = asId(req.params.id)
+ if (!id) return res.status(400).json({ error: 'bad event id' })
+ const row = await definitionsDb.getById(id)
+ if (!row) return res.status(404).json({ error: 'no such event definition' })
+ return res.json({ event: shapeDefinition(row) })
+}
+
+/** GET /api/v1/admin/events/:id/versions */
+exports.listVersions = async (req, res) => {
+ const id = asId(req.params.id)
+ if (!id) return res.status(400).json({ error: 'bad event id' })
+ const row = await definitionsDb.getById(id)
+ if (!row) return res.status(404).json({ error: 'no such event definition' })
+ const rows = await versionsDb.listForDefinition(id)
+ return res.json({
+ versions: rows.map((v) => ({
+ id: v.id,
+ version: v.version,
+ publishedAt: v.published_at,
+ publishedBy: v.published_by,
+ publishedByUsername: v.published_by_username,
+ current: v.id === row.current_version_id,
+ })),
+ })
+}
+
+/** POST /api/v1/admin/events */
+exports.create = async (req, res) => {
+ const result = await definitions.create(req.body, req.user.id)
+ if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
+ await activity.log({
+ req,
+ action: 'event.definition.created',
+ detail: { id: result.id, title: result.definition.title },
+ })
+ return res.status(201).json({ event: shapeDefinition(result.definition) })
+}
+
+/** PUT /api/v1/admin/events/:id */
+exports.update = async (req, res) => {
+ const id = asId(req.params.id)
+ if (!id) return res.status(400).json({ error: 'bad event id' })
+ const result = await definitions.save(id, req.body, req.user.id)
+ if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
+ await activity.log({
+ req,
+ action: 'event.definition.updated',
+ detail: { id, title: result.definition.title },
+ })
+ return res.json({ event: shapeDefinition(result.definition) })
+}
+
+/** POST /api/v1/admin/events/:id/publish */
+exports.publish = async (req, res) => {
+ const id = asId(req.params.id)
+ if (!id) return res.status(400).json({ error: 'bad event id' })
+ const result = await definitions.publish(id, req.user.id)
+ if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
+ await activity.log({
+ req,
+ action: 'event.definition.published',
+ detail: { id, version: result.version, versionId: result.versionId },
+ })
+ return res.json({
+ event: shapeDefinition(result.definition),
+ version: result.version,
+ versionId: result.versionId,
+ })
+}
+
+/** DELETE /api/v1/admin/events/:id — archive, never a hard delete */
+exports.archive = async (req, res) => {
+ const id = asId(req.params.id)
+ if (!id) return res.status(400).json({ error: 'bad event id' })
+ const result = await definitions.archive(id, req.user.id)
+ if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.definition.archived', detail: { id } })
+ return res.json({ event: shapeDefinition(result.definition) })
+}
+
+/**
+ * POST /api/v1/admin/events/:id/runs
+ *
+ * Creates the occurrence. It stays `scheduled` until Phase 2's runner exists,
+ * and the response says so through `pending: true` rather than by pretending
+ * something started.
+ */
+exports.startRun = async (req, res) => {
+ const id = asId(req.params.id)
+ if (!id) return res.status(400).json({ error: 'bad event id' })
+ const result = await runs.create(
+ id,
+ {
+ scope: req.body?.scope,
+ scheduledFor: req.body?.scheduledFor,
+ rehearsal: Boolean(req.body?.rehearsal),
+ params: req.body?.params ?? null,
+ },
+ req.user.id,
+ )
+ if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
+ if (result.created) {
+ await activity.log({
+ req,
+ action: 'event.run.created',
+ detail: {
+ definitionId: id,
+ runId: result.run.id,
+ rehearsal: Boolean(req.body?.rehearsal),
+ },
+ })
+ }
+ return res.status(result.created ? 201 : 200).json({
+ run: shapeRun(result.run),
+ created: result.created,
+ })
+}
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
new file mode 100644
index 0000000..36c28b6
--- /dev/null
+++ b/server/src/router/v1/admin/events.router.js
@@ -0,0 +1,201 @@
+// Admin · Events — definitions, versions, the action catalog and run reads
+// (EVENTS.md § API surface, Phase 1).
+//
+// Mounted at /api/v1/admin/events by admin/index.js, which has already applied
+// `noindex, isLoggedIn, staffOnly`. Every route below re-gates to the tier
+// EVENTS.md § API surface names for it.
+//
+// **The gates are the real ones from this phase, not placeholders.** §N2 decided
+// that publish and start are `admin` ONLY — a moderator keeps live control of a
+// run already in flight and nothing more — and the switchboard those gates will
+// eventually consult (`event_action_settings`, Phase 6) does not exist yet. They
+// are here anyway, because a button that is admin-only later and open now is a
+// gate nobody notices was missing.
+//
+// Reads are staff-wide. `verify` (admin, editor) is Phase 6's, and the live run
+// controls (admin, moderator) are Phase 3's — neither is stubbed here, because
+// nothing is in flight until Phase 2 builds the runner.
+//
+// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
+// `/runs` are never read as an event id.
+
+const express = require('express')
+
+const controller = require('./events.controller')
+const { requireRole } = require('../../../utils/auth')
+
+const eventsRouter = express.Router()
+const adminOnly = requireRole('admin')
+const adminOrEditor = requireRole('admin', 'editor')
+
+// ── The catalog and the vocabularies, served from the registries ───────────
+
+eventsRouter.get(
+ '/catalog',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
+ // #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The registered actions and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.catalog,
+)
+
+eventsRouter.get(
+ '/series',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'List the event series a definition may belong to'
+ // #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.listSeries,
+)
+
+// ── Runs ──────────────────────────────────────────────────────────────────
+//
+// Declared ahead of /:id so the literal path is never read as a definition id.
+
+eventsRouter.get(
+ '/runs',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'List event runs across every definition, newest occurrence first'
+ // #swagger.description = 'A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['definitionId'] = { in: 'query', description: 'Only runs of this definition', required: false, schema: { type: 'integer' } }
+ // #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status', required: false, schema: { type: 'string' } }
+ // #swagger.parameters['limit'] = { in: 'query', description: 'How many rows, 1..500 (default 100)', required: false, schema: { type: 'integer' } }
+ /* #swagger.responses[200] = { description: 'The runs', content: { "application/json": { schema: { type: "object", properties: { runs: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.listRuns,
+)
+
+eventsRouter.get(
+ '/runs/:runId',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
+ // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The run, its steps and the status counts', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.getRun,
+)
+
+eventsRouter.get(
+ '/runs/:runId/log',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'The diagnostic log for one run'
+ // #swagger.description = 'Structured and queryable, unlike activity_log.detail: this is what answers "why did not phase 3 start?" without reading server logs. The audit of who published what is written separately to the activity log.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['limit'] = { in: 'query', description: 'How many lines, 1..2000 (default 500)', required: false, schema: { type: 'integer' } }
+ /* #swagger.responses[200] = { description: 'The log, newest first, and the closed set of line kinds', content: { "application/json": { schema: { type: "object", properties: { log: { type: "array", items: { type: "object", additionalProperties: true } }, kinds: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.getRunLog,
+)
+
+// ── Definitions ───────────────────────────────────────────────────────────
+
+eventsRouter.get(
+ '/',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'List every event definition with its state and current version'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['state'] = { in: 'query', description: 'Only definitions in this state: draft, ready or archived', required: false, schema: { type: 'string' } }
+ /* #swagger.responses[200] = { description: 'The definitions', content: { "application/json": { schema: { type: "object", properties: { events: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.list,
+)
+
+eventsRouter.post(
+ '/',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Create a draft event definition'
+ // #swagger.description = 'Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } }, required: ["title"] } } } } */
+ /* #swagger.responses[201] = { description: 'The created draft', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.create,
+)
+
+eventsRouter.get(
+ '/:id',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'One event definition, including its working spec'
+ // #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.get,
+)
+
+eventsRouter.put(
+ '/:id',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Edit a definition and its working spec'
+ // #swagger.description = 'Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, body: { type: "string", nullable: true }, imageUrl: { type: "string", nullable: true }, seriesId: { type: "integer", nullable: true }, seriesOrder: { type: "integer" }, concurrencyKey: { type: "string", nullable: true }, graceSeconds: { type: "integer" }, timezone: { type: "string" }, spec: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[200] = { description: 'The saved definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[400] = { description: 'Validation failed; every problem is listed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[409] = { description: 'The definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ adminOrEditor,
+ controller.update,
+)
+
+eventsRouter.get(
+ '/:id/versions',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'The version history of one definition'
+ // #swagger.description = 'Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The versions, newest first', content: { "application/json": { schema: { type: "object", properties: { versions: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.listVersions,
+)
+
+eventsRouter.post(
+ '/:id/publish',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
+ // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */
+ /* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ controller.publish,
+)
+
+eventsRouter.delete(
+ '/:id',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Archive a definition — never a hard delete'
+ // #swagger.description = 'Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered 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.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The archived definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[409] = { description: 'A run of this definition is still in flight', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ controller.archive,
+)
+
+eventsRouter.post(
+ '/:id/runs',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Create an occurrence of a published definition'
+ // #swagger.description = 'Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { scope: { type: "string", description: "Module-opaque. Core stores it verbatim and never parses it." }, scheduledFor: { type: "string", description: "UTC instant; defaults to now" }, rehearsal: { type: "boolean" }, params: { type: "object", additionalProperties: true, description: "Rendered into the definition concurrency_key template" } } } } } } */
+ /* #swagger.responses[201] = { description: 'The occurrence was created', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */
+ /* #swagger.responses[200] = { description: 'The occurrence already existed and is returned unchanged', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, created: { type: "boolean" } } } } } } */
+ /* #swagger.responses[409] = { description: 'The definition is not ready, or has no published version', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ controller.startRun,
+)
+
+module.exports = eventsRouter
diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js
index f4bf0e2..8d0f668 100644
--- a/server/src/router/v1/admin/index.js
+++ b/server/src/router/v1/admin/index.js
@@ -31,6 +31,7 @@ const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const engagementRouter = require('./engagement.router')
+const eventsRouter = require('./events.router')
const teamsRouter = require('./teams.router')
const teamsVoiceRouter = require('./teamsVoice.router')
const dashboardRouter = require('./dashboard.router')
@@ -87,6 +88,13 @@ adminRouter.use('/modules', modulesRouter)
// /modules above and for a related reason: this is the surface that decides who
// the site sends mail to.
adminRouter.use('/engagement', engagementRouter)
+// The Event System (EVENTS.md § API surface, Phase 1). Staff-wide for the reads
+// and gated per route for the writes, which is where §N2's asymmetry lives:
+// publish and start are `admin` ONLY, while the live run controls Phase 3 adds
+// are `admin` + `moderator`. Starting commits the deployment to an unattended
+// world change; cancelling is incident response, and they are deliberately not
+// the same gate.
+adminRouter.use('/events', eventsRouter)
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 9ce1599..608068f 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3363,6 +3363,1093 @@
]
}
},
+ "/api/v1/admin/events": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "List every event definition with its state and current version",
+ "description": "",
+ "parameters": [
+ {
+ "name": "state",
+ "in": "query",
+ "description": "Only definitions in this state: draft, ready or archived",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The definitions",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "events": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Create a draft event definition",
+ "description": "Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.",
+ "responses": {
+ "201": {
+ "description": "The created draft",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "event": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation failed; every problem is listed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "summary": {
+ "type": "string",
+ "nullable": true
+ },
+ "body": {
+ "type": "string",
+ "nullable": true
+ },
+ "imageUrl": {
+ "type": "string",
+ "nullable": true
+ },
+ "seriesId": {
+ "type": "integer",
+ "nullable": true
+ },
+ "seriesOrder": {
+ "type": "integer"
+ },
+ "concurrencyKey": {
+ "type": "string",
+ "nullable": true
+ },
+ "graceSeconds": {
+ "type": "integer"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "spec": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "required": [
+ "title"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/events/catalog": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "List every registered event action, with its param schema, risk class and reversibility",
+ "description": "Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.",
+ "responses": {
+ "200": {
+ "description": "The registered actions and the vocabularies over them",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "actions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "risks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "reversible": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "paramTypes": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "onFailure": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "onFailureByRisk": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "scheduleKinds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "limits": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/runs": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "List event runs across every definition, newest occurrence first",
+ "description": "A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.",
+ "parameters": [
+ {
+ "name": "definitionId",
+ "in": "query",
+ "description": "Only runs of this definition",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Only runs in this status",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "description": "How many rows, 1..500 (default 100)",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The runs",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "runs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/runs/{runId}": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
+ "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The run, its steps and the status counts",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "counts": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No such run",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/runs/{runId}/log": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "The diagnostic log for one run",
+ "description": "Structured and queryable, unlike activity_log.detail: this is what answers \"why did not phase 3 start?\" without reading server logs. The audit of who published what is written separately to the activity log.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "description": "How many lines, 1..2000 (default 500)",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The log, newest first, and the closed set of line kinds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "log": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "kinds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No such run",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/series": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "List the event series a definition may belong to",
+ "description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.",
+ "responses": {
+ "200": {
+ "description": "The series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "series": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "slug": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "ordering": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/{id}": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "One event definition, including its working spec",
+ "description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The definition",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "event": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No such definition",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "put": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Edit a definition and its working spec",
+ "description": "Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The saved definition",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "event": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation failed; every problem is listed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The definition is archived",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "summary": {
+ "type": "string",
+ "nullable": true
+ },
+ "body": {
+ "type": "string",
+ "nullable": true
+ },
+ "imageUrl": {
+ "type": "string",
+ "nullable": true
+ },
+ "seriesId": {
+ "type": "integer",
+ "nullable": true
+ },
+ "seriesOrder": {
+ "type": "integer"
+ },
+ "concurrencyKey": {
+ "type": "string",
+ "nullable": true
+ },
+ "graceSeconds": {
+ "type": "integer"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "spec": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Archive a definition — never a hard delete",
+ "description": "Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered 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.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The archived definition",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "event": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "A run of this definition is still in flight",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/{id}/publish": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Snapshot the working spec into an immutable version and mark the definition ready",
+ "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The definition, now ready, and the version that was cut",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "event": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "version": {
+ "type": "integer"
+ },
+ "versionId": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The spec is invalid, or no phase has any steps",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "A step names an action no module registers, or the definition is archived",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/{id}/runs": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Create an occurrence of a published definition",
+ "description": "Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The occurrence already existed and is returned unchanged",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "created": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "201": {
+ "description": "The occurrence was created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "created": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The definition is not ready, or has no published version",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "scope": {
+ "type": "string",
+ "description": "Module-opaque. Core stores it verbatim and never parses it."
+ },
+ "scheduledFor": {
+ "type": "string",
+ "description": "UTC instant; defaults to now"
+ },
+ "rehearsal": {
+ "type": "boolean"
+ },
+ "params": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Rendered into the definition concurrency_key template"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/events/{id}/versions": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "The version history of one definition",
+ "description": "Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The versions, newest first",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "versions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No such definition",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/invites": {
"post": {
"tags": [
diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js
new file mode 100644
index 0000000..c3882e1
--- /dev/null
+++ b/server/test/eventActionRegistry.test.js
@@ -0,0 +1,196 @@
+// ── The event action registry (EVENTS.md §F, Phase 1) ──────────────────────
+//
+// Phase 1's acceptance criteria for the registry half, one test apiece:
+//
+// • core's three actions register on every boot and appear in the catalog
+// • the catalog never carries a callable — no `perform`, `revert` or `cost`
+// • a module registering an un-namespaced action fails, with the holder named
+// • the closed sets are closed: an invented risk or reversibility is refused
+// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION,
+// not discovered at teardown when something has already been created
+// • an action id and a trigger id are DIFFERENT namespaces, so one id may
+// legitimately be both — the property the audience registry established and
+// this one inherits
+//
+// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
+// utils/discordAnnounce, which reaches the pool at require time.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const coreEventActions = require('../src/config/coreEventActions')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+beforeEach(() => registries._reset())
+afterEach(() => registries._reset())
+
+const ok = (over = {}) => ({
+ id: 'demo.thing.do',
+ label: 'Do the thing',
+ risk: 'change',
+ reversible: 'none',
+ perform: async () => ({ ok: true }),
+ ...over,
+})
+
+const register = (owner, entries) => {
+ const api = registries.stage(owner)
+ api.registerEventActions(entries)
+ registries.apply(api.staged)
+}
+
+test('core registers its three actions on every boot', () => {
+ registries.registerCore()
+ const ids = registries.allEventActions().map((a) => a.id)
+ assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
+ assert.equal(ids.length, coreEventActions.ACTIONS.length)
+})
+
+test('the catalog carries no callable', () => {
+ registries.registerCore()
+ for (const action of registries.allEventActions()) {
+ assert.equal(action.perform, undefined, `${action.id} leaked perform`)
+ assert.equal(action.revert, undefined, `${action.id} leaked revert`)
+ assert.equal(action.cost, undefined, `${action.id} leaked cost`)
+ }
+ // …and the runner's own lookup still has it, which is the half that makes the
+ // stripping a boundary rather than a deletion.
+ assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
+})
+
+test('core placeholders refuse rather than claiming success', async () => {
+ // Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters
+ // because `ok: true` on an action that did nothing is a recorded world change
+ // that did not occur — the one wrong answer a stub can give.
+ registries.registerCore()
+ for (const id of ['core.announce', 'core.wait', 'core.cue']) {
+ const answer = await registries.eventAction(id).perform({})
+ assert.equal(answer.ok, false)
+ assert.equal(answer.retry, false)
+ assert.match(answer.error, new RegExp(id.replace('.', '\\.')))
+ }
+})
+
+test('an action must be namespaced to its owner, and the holder is named', () => {
+ assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/)
+
+ register('demo', [ok()])
+ assert.throws(
+ () => register('rival', [ok({ id: 'demo.thing.do' })]),
+ /already registered by "demo"/,
+ )
+})
+
+test('the same id twice in one batch is refused', () => {
+ assert.throws(() => register('demo', [ok(), ok()]), /registered twice/)
+})
+
+test('risk and reversibility are closed sets with no default', () => {
+ assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/)
+ assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/)
+ assert.throws(
+ () => register('demo', [ok({ reversible: undefined })]),
+ /needs a reversible class/,
+ )
+ assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/)
+})
+
+test("reversible: 'ledger' without revert() is refused at registration", () => {
+ assert.throws(
+ () => register('demo', [ok({ reversible: 'ledger' })]),
+ /is reversible: 'ledger' but has no revert\(\)/,
+ )
+ // And the mirror: a revert() nothing will ever call is a promise core does not
+ // keep, so it is refused just as loudly.
+ assert.throws(
+ () => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]),
+ /declares revert\(\) but is reversible: 'none'/,
+ )
+ register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })])
+ assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
+})
+
+test('perform() is required and cost must be a function', () => {
+ assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/)
+ assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
+})
+
+test('every param needs a type and an example', () => {
+ const withParams = (params) => ok({ params })
+ assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/)
+ assert.throws(
+ () => register('demo', [withParams([{ name: 'x', type: 'int' }])]),
+ /needs an example/,
+ )
+ assert.throws(
+ () => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]),
+ /bad param name/,
+ )
+ assert.throws(
+ () =>
+ register('demo', [
+ withParams([
+ { name: 'x', type: 'int', example: 1 },
+ { name: 'x', type: 'int', example: 2 },
+ ]),
+ ]),
+ /declared twice/,
+ )
+ register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])])
+ const [param] = registries.eventAction('demo.thing.do').params
+ assert.deepEqual(param, {
+ name: 'x',
+ type: 'int',
+ required: false,
+ example: 12,
+ source: 'demo.options.x',
+ description: '',
+ })
+})
+
+test('budgetMs defaults, and is bounded', () => {
+ register('demo', [ok()])
+ assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS)
+ registries._reset()
+ assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/)
+ assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/)
+})
+
+test('actions and triggers are different namespaces, so one id may be both', () => {
+ // The property §F states and the audience registry established first. A verb
+ // called `demo.raid.start` and an event called `demo.raid.start` are two
+ // unrelated declarations, and forbidding the pair would forbid the most
+ // natural names a module will ever want.
+ const api = registries.stage('demo')
+ api.registerEventTriggers([
+ { id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' },
+ ])
+ api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })])
+ registries.apply(api.staged)
+
+ assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started')
+ assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid')
+})
+
+test('a whole batch is refused or taken, never half', () => {
+ assert.throws(
+ () => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]),
+ /needs a risk class/,
+ )
+ // The shape check throws at the CALL, before anything is staged, so nothing
+ // from the batch is visible.
+ assert.equal(registries.eventAction('demo.thing.do'), null)
+})
+
+test('_reset() hands the process back', () => {
+ registries.registerCore()
+ assert.equal(registries.allEventActions().length, 3)
+ registries._reset()
+ assert.equal(registries.allEventActions().length, 0)
+ assert.equal(registries.isEventAction('core.wait'), false)
+})
diff --git a/server/test/eventSpec.test.js b/server/test/eventSpec.test.js
new file mode 100644
index 0000000..307cdad
--- /dev/null
+++ b/server/test/eventSpec.test.js
@@ -0,0 +1,231 @@
+// ── The event spec validator (EVENTS.md §C/§D, Phase 1) ────────────────────
+//
+// The boundary that decides whether a version may exist. Its interesting cases
+// are all about time rather than shape:
+//
+// • a step's params are checked against the action's DECLARED params, and the
+// action version it was authored against is captured at save
+// • `on_failure` is defaulted from the risk class, because a `change` action
+// that fell back to `skip` would advance a run over a half-changed world
+// • an unregistered action is refused on a NEW step and KEPT on an existing
+// one — the rule `engagementRules.model` established for a dormant trigger,
+// for the same reason: an uninstall must not be destructive after the fact
+// • a dormant step blocks a PUBLISH and never a SAVE
+// • two phases may not share a key, because `UNIQUE (run_id, phase, seq)`
+// would silently collapse them into one at materialisation
+// • a key a later phase owns (`advance`, `announcements`) is REFUSED rather
+// than preserved, so no corpus of unvalidated specs accumulates
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const spec = require('../src/events/spec')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+beforeEach(() => {
+ registries._reset()
+ registries.registerCore()
+ const api = registries.stage('demo')
+ api.registerEventActions([
+ {
+ id: 'demo.world.change',
+ label: 'Change the world',
+ risk: 'change',
+ reversible: 'none',
+ version: 4,
+ params: [
+ { name: 'region', type: 'string', required: true, example: 'Yew' },
+ { name: 'count', type: 'int', required: true, example: 12 },
+ { name: 'hue', type: 'int', required: false, example: 1157 },
+ ],
+ perform: async () => ({ ok: true }),
+ },
+ {
+ id: 'demo.world.wreck',
+ label: 'Wreck the world',
+ risk: 'irreversible',
+ reversible: 'none',
+ perform: async () => ({ ok: true }),
+ },
+ ])
+ registries.apply(api.staged)
+})
+afterEach(() => registries._reset())
+
+const oneStep = (step) => ({
+ schedule: { kind: 'manual' },
+ phases: [{ key: 'main', label: 'Main', steps: [step] }],
+})
+
+test('the empty spec is valid, and is what a new draft carries', () => {
+ const result = spec.validate(spec.emptySpec())
+ assert.equal(result.ok, true)
+ assert.equal(result.spec.phases.length, 1)
+ assert.deepEqual(result.spec.schedule, { kind: 'manual' })
+})
+
+test('params are checked against the declaration and coerced', () => {
+ const result = spec.validate(
+ oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 12 } }),
+ )
+ assert.equal(result.ok, true)
+ const [step] = result.spec.phases[0].steps
+ assert.deepEqual(step.params, { region: 'Yew', count: 12 })
+ // Captured from the declaration, not from the request: it is what lets a later
+ // bump warn in the editor instead of dispatching a mistyped parameter.
+ assert.equal(step.actionVersion, 4)
+ assert.equal(step.dormant, false)
+})
+
+test('a missing required param, a wrong type and an unknown param are all refused', () => {
+ const missing = spec.validate(oneStep({ actionId: 'demo.world.change', params: { region: 'Yew' } }))
+ assert.equal(missing.ok, false)
+ assert.match(missing.errors.join('\n'), /"count" is required/)
+
+ const wrong = spec.validate(
+ oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 'twelve' } }),
+ )
+ assert.equal(wrong.ok, false)
+ assert.match(wrong.errors.join('\n'), /"count" expected an integer/)
+
+ // An unknown param is an ERROR, not a silent drop: an author who typed
+ // `regions` has written a step that would dispatch with the region missing,
+ // and dropping the key makes that look like it saved cleanly.
+ const typo = spec.validate(
+ oneStep({ actionId: 'demo.world.change', params: { regions: 'Yew', count: 1 } }),
+ )
+ assert.equal(typo.ok, false)
+ assert.match(typo.errors.join('\n'), /"regions" is not a param of demo\.world\.change/)
+})
+
+test('on_failure is defaulted from the risk class', () => {
+ const notify = spec.validate(
+ oneStep({ actionId: 'core.announce', params: { leg: 'discord', body: 'hi' } }),
+ )
+ assert.equal(notify.spec.phases[0].steps[0].onFailure, 'skip')
+
+ const change = spec.validate(
+ oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 1 } }),
+ )
+ assert.equal(change.spec.phases[0].steps[0].onFailure, 'pause')
+
+ const irreversible = spec.validate(oneStep({ actionId: 'demo.world.wreck' }))
+ assert.equal(irreversible.spec.phases[0].steps[0].onFailure, 'abort_run')
+
+ // An author may still choose, within the closed set.
+ const chosen = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'skip' }))
+ assert.equal(chosen.spec.phases[0].steps[0].onFailure, 'skip')
+ const invented = spec.validate(oneStep({ actionId: 'demo.world.wreck', onFailure: 'shrug' }))
+ assert.equal(invented.ok, false)
+ assert.match(invented.errors.join('\n'), /onFailure: must be one of/)
+})
+
+test('a NEW step may not name an unregistered action', () => {
+ const result = spec.validate(oneStep({ actionId: 'gone.module.verb' }))
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /no module registers "gone\.module\.verb"/)
+})
+
+test('an EXISTING step keeps its action when the module goes away, and is marked dormant', () => {
+ const saved = spec.validate(
+ oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }),
+ ).spec
+
+ // The module is uninstalled between one save and the next.
+ registries._reset()
+ registries.registerCore()
+
+ const again = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) })
+ assert.equal(again.ok, true, again.errors && again.errors.join('\n'))
+ const [step] = again.spec.phases[0].steps
+ assert.equal(step.dormant, true)
+ // Params pass through untouched: the only thing that could validate them left
+ // with the module.
+ assert.deepEqual(step.params, { region: 'Yew', count: 3 })
+
+ // …and that is exactly what publish refuses.
+ const publishable = spec.publishable(again.spec)
+ assert.equal(publishable.ok, false)
+ assert.deepEqual(publishable.dormant, ['demo.world.change'])
+})
+
+test('validate accepts its own output — a saved spec is re-validated on every save', () => {
+ // The property the dormancy test above found the hard way: `validate` adds
+ // `actionVersion` and `dormant`, and a validator that then refused its own
+ // fields would make the SECOND save of any definition impossible, and publish
+ // — which re-validates before snapshotting — impossible full stop.
+ const once = spec.validate(
+ oneStep({ actionId: 'demo.world.change', params: { region: 'Yew', count: 3 } }),
+ )
+ const twice = spec.validate(once.spec)
+ assert.equal(twice.ok, true, twice.errors && twice.errors.join('\n'))
+ assert.deepEqual(twice.spec, once.spec)
+})
+
+test('two phases may not share a key', () => {
+ const result = spec.validate({
+ schedule: { kind: 'manual' },
+ phases: [
+ { key: 'main', label: 'One', steps: [] },
+ { key: 'main', label: 'Two', steps: [] },
+ ],
+ })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /used by more than one phase/)
+})
+
+test('a key a later phase owns is refused, not silently preserved', () => {
+ const top = spec.validate({ schedule: { kind: 'manual' }, phases: [], announcements: [] })
+ assert.equal(top.ok, false)
+ assert.match(top.errors.join('\n'), /unknown key "announcements"/)
+
+ const phase = spec.validate({
+ schedule: { kind: 'manual' },
+ phases: [{ key: 'main', label: 'Main', steps: [], advance: { after: '30m' } }],
+ })
+ assert.equal(phase.ok, false)
+ assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
+})
+
+test('only the manual schedule exists in this phase', () => {
+ const weekly = spec.validate({
+ schedule: { kind: 'weekly', days: ['fri'], time: '20:00' },
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+ })
+ assert.equal(weekly.ok, false)
+ assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/)
+})
+
+test('every problem is reported, not just the first', () => {
+ const result = spec.validate({
+ schedule: { kind: 'manual' },
+ phases: [
+ { key: 'BAD KEY', label: '', steps: [{ actionId: 'demo.world.change', params: {} }] },
+ ],
+ })
+ assert.equal(result.ok, false)
+ const joined = result.errors.join('\n')
+ assert.match(joined, /bad phase key/)
+ assert.match(joined, /a phase needs a label/)
+ assert.match(joined, /"region" is required/)
+ assert.match(joined, /"count" is required/)
+})
+
+test('the size bounds hold', () => {
+ const many = {
+ schedule: { kind: 'manual' },
+ phases: Array.from({ length: spec.MAX_PHASES + 1 }, (_, i) => ({
+ key: `p${i}`,
+ label: `P${i}`,
+ steps: [],
+ })),
+ }
+ const result = spec.validate(many)
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`))
+})
diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js
new file mode 100644
index 0000000..3f589a9
--- /dev/null
+++ b/server/test/eventsAdmin.test.js
@@ -0,0 +1,597 @@
+// ── The events admin surface (EVENTS.md § API surface, Phase 1) ────────────
+//
+// `eventSpec.test.js` covers the spec validator and `eventActionRegistry.test.js`
+// the registry; re-asserting either here would be a second copy of a test rather
+// than a second test. What is genuinely new is what the SURFACE decides:
+//
+// • **publish snapshots.** It cuts an immutable version, points the definition
+// at it, and a later edit does not touch the version a run would pin.
+// • **publish re-validates against the registries as they stand now**, not
+// against the save that wrote the spec. A module uninstalled in between must
+// block the publish, because the alternative is a run that fails at dispatch
+// with the world half-changed.
+// • **an empty event does not publish.** It would run cleanly and do nothing,
+// which reads as a broken run rather than an empty one.
+// • **the slug is frozen after create**, because the public event page lives
+// at it and a retitle must not break a posted link.
+// • **archiving is refused while a run is in flight**, and there is no hard
+// delete at all.
+// • **creating an occurrence twice creates ONE run.** The unique index is what
+// makes that true, so the second call answers `created: false` with the
+// existing row rather than erroring.
+// • **a created run stays `scheduled`.** There is no runner until Phase 2, and
+// that has to be visible as the correct state rather than as a stall.
+//
+// The `.db` layer is stubbed in-memory and the real models and controllers run
+// against it, the shape `engagementAdmin.test.js` uses.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const ctrl = require('../src/router/v1/admin/events.controller')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
+const versionsDb = require('../src/model/events/eventVersions.db')
+const runsDb = require('../src/model/events/eventRuns.db')
+const stepsDb = require('../src/model/events/eventRunSteps.db')
+const logDb = require('../src/model/events/eventRunLog.db')
+const seriesDb = require('../src/model/events/eventSeries.db')
+const activity = require('../src/model/activity/activity.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+// ── In-memory stand-ins ────────────────────────────────────────────────────
+
+let store
+const originals = {}
+for (const [name, mod] of [
+ ['definitionsDb', definitionsDb],
+ ['versionsDb', versionsDb],
+ ['runsDb', runsDb],
+ ['stepsDb', stepsDb],
+ ['logDb', logDb],
+ ['seriesDb', seriesDb],
+ ['activity', activity],
+]) {
+ originals[name] = { mod, fns: { ...mod } }
+}
+const restoreOriginals = () => {
+ for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
+}
+
+// The occurrence key, as a string, so the stub can enforce the UNIQUE index the
+// real table enforces. Reproducing it is the point of several tests below: the
+// index — not the claim — is what makes "one run per occurrence per scope" true.
+const occurrenceKey = (definitionId, scope, when) =>
+ `${definitionId}|${scope || ''}|${new Date(when).toISOString()}`
+
+function installStubs() {
+ store = {
+ definitions: new Map(),
+ versions: new Map(),
+ runs: new Map(),
+ steps: new Map(),
+ log: [],
+ series: new Map(),
+ occurrences: new Set(),
+ nextDefinition: 1,
+ nextVersion: 1,
+ nextRun: 1,
+ nextStep: 1,
+ }
+
+ const shape = (d) => ({
+ ...d,
+ series_name: store.series.get(d.series_id)?.name ?? null,
+ series_slug: store.series.get(d.series_id)?.slug ?? null,
+ current_version: store.versions.get(d.current_version_id)?.version ?? null,
+ })
+
+ definitionsDb.list = async ({ state = null } = {}) =>
+ [...store.definitions.values()].filter((d) => !state || d.state === state).map(shape)
+ definitionsDb.getById = async (id) =>
+ store.definitions.has(id) ? shape(store.definitions.get(id)) : undefined
+ definitionsDb.getBySlug = async (slug) =>
+ [...store.definitions.values()].filter((d) => d.slug === slug).map(shape)[0]
+ definitionsDb.slugTaken = async (slug, exceptId = null) =>
+ [...store.definitions.values()].some((d) => d.slug === slug && d.id !== exceptId)
+ definitionsDb.insert = async (d) => {
+ const id = store.nextDefinition++
+ store.definitions.set(id, {
+ id,
+ state: 'draft',
+ current_version_id: null,
+ created_at: new Date(),
+ updated_at: new Date(),
+ ...d,
+ })
+ return id
+ }
+ definitionsDb.update = async (id, d) => {
+ const existing = store.definitions.get(id)
+ if (existing) Object.assign(existing, d, { updated_at: new Date() })
+ }
+ definitionsDb.markReady = async (id, versionId, userId) => {
+ const existing = store.definitions.get(id)
+ if (existing) {
+ Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId })
+ }
+ }
+ definitionsDb.archive = async (id, userId) => {
+ const existing = store.definitions.get(id)
+ if (existing) Object.assign(existing, { state: 'archived', updated_by: userId })
+ }
+
+ versionsDb.listForDefinition = async (definitionId) =>
+ [...store.versions.values()]
+ .filter((v) => v.definition_id === definitionId)
+ .sort((a, b) => b.version - a.version)
+ versionsDb.getById = async (id) => store.versions.get(id) || undefined
+ versionsDb.nextVersion = async (definitionId) =>
+ [...store.versions.values()].filter((v) => v.definition_id === definitionId).length + 1
+ versionsDb.insert = async (definitionId, version, spec, userId) => {
+ const id = store.nextVersion++
+ // Deep-copied on the way in, because the whole point of a version is that a
+ // later edit of the working spec cannot reach it. A stub that stored the
+ // reference would make the snapshot test pass for the wrong reason.
+ store.versions.set(id, {
+ id,
+ definition_id: definitionId,
+ version,
+ spec: JSON.parse(JSON.stringify(spec)),
+ published_at: new Date(),
+ published_by: userId,
+ })
+ return id
+ }
+
+ const shapeRun = (r) => ({
+ ...r,
+ definition_title: store.definitions.get(r.definition_id)?.title ?? null,
+ definition_slug: store.definitions.get(r.definition_id)?.slug ?? null,
+ version_number: store.versions.get(r.version_id)?.version ?? null,
+ })
+
+ runsDb.list = async ({ definitionId = null, status = null } = {}) =>
+ [...store.runs.values()]
+ .filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status))
+ .map(shapeRun)
+ runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined)
+ runsDb.materialise = async (run) => {
+ const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for)
+ if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job
+ store.occurrences.add(key)
+ const id = store.nextRun++
+ store.runs.set(id, {
+ id,
+ status: 'scheduled',
+ health: 'ok',
+ cleanup_status: 'not_required',
+ current_phase: null,
+ started_at: null,
+ ended_at: null,
+ last_error: null,
+ created_at: new Date(),
+ ...run,
+ scheduled_for: new Date(run.scheduled_for),
+ rehearsal: Boolean(run.rehearsal),
+ })
+ return id
+ }
+ runsDb.findOccurrence = async (definitionId, scope, when) =>
+ [...store.runs.values()]
+ .filter(
+ (r) => occurrenceKey(r.definition_id, r.scope, r.scheduled_for) === occurrenceKey(definitionId, scope, when),
+ )
+ .map(shapeRun)[0]
+ runsDb.countActiveForDefinition = async (definitionId) =>
+ [...store.runs.values()].filter(
+ (r) =>
+ r.definition_id === definitionId &&
+ ['scheduled', 'starting', 'running', 'paused', 'ending'].includes(r.status),
+ ).length
+
+ stepsDb.listForRun = async (runId) =>
+ [...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq)
+ stepsDb.getById = async (id) => store.steps.get(id) || undefined
+ stepsDb.materialisePhase = async (runId, phase, steps) => {
+ steps.forEach((step, seq) => {
+ const taken = [...store.steps.values()].some(
+ (s) => s.run_id === runId && s.phase === phase && s.seq === seq,
+ )
+ if (taken) return
+ const id = store.nextStep++
+ store.steps.set(id, {
+ id,
+ run_id: runId,
+ phase,
+ seq,
+ action_id: step.actionId,
+ params: step.params || {},
+ action_version: step.actionVersion || 1,
+ status: 'pending',
+ due_at: null,
+ attempts: 0,
+ on_failure: step.onFailure || 'pause',
+ // The real materialiser stamps this from the row's own id, so the stub
+ // does too: a key that varied by attempt would defeat the whole retry
+ // story, and a stub that faked it would hide that.
+ idempotency_key: stepsDb.idempotencyKey(runId, id),
+ last_error: null,
+ started_at: null,
+ finished_at: null,
+ })
+ })
+ return stepsDb.listForRun(runId)
+ }
+ stepsDb.statusCounts = async (runId) => {
+ const counts = {}
+ for (const s of store.steps.values()) {
+ if (s.run_id === runId) counts[s.status] = (counts[s.status] || 0) + 1
+ }
+ return counts
+ }
+
+ logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
+ logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {
+ store.log.push({ id: store.log.length + 1, run_id: runId, step_id: stepId, kind, phase, detail, at: new Date() })
+ return true
+ }
+
+ seriesDb.list = async () => [...store.series.values()]
+ seriesDb.getById = async (id) => store.series.get(id) || null
+ seriesDb.exists = async (id) => store.series.has(id)
+
+ // The audit log is a side effect, not a subject: it writes to a real table and
+ // never throws into the request path, so the stub records and stays quiet.
+ activity.log = async (entry) => {
+ store.log.push({ audit: true, ...entry })
+ return true
+ }
+}
+
+// ── Fixtures ───────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ registries._reset()
+ registries.registerCore()
+ installStubs()
+})
+afterEach(() => {
+ registries._reset()
+ restoreOriginals()
+})
+
+/** A module whose one action a definition can be built around. */
+function registerDemoModule() {
+ const api = registries.stage('demo')
+ api.registerEventActions([
+ {
+ id: 'demo.world.change',
+ label: 'Change the world',
+ risk: 'change',
+ reversible: 'none',
+ params: [{ name: 'region', type: 'string', required: true, example: 'Yew' }],
+ perform: async () => ({ ok: true }),
+ },
+ ])
+ registries.apply(api.staged)
+}
+
+const announceStep = (body = 'The gates open at dusk.') => ({
+ actionId: 'core.announce',
+ params: { leg: 'discord', body },
+})
+
+const draftBody = (over = {}) => ({
+ title: 'The Siege of Cove',
+ summary: 'An invasion, in three phases.',
+ timezone: 'Europe/Berlin',
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }],
+ },
+ ...over,
+})
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ status(c) { this.statusCode = c; return this },
+ json(b) { this.body = b; return this },
+ }
+}
+
+async function call(handler, req) {
+ const res = mockRes()
+ let thrown = null
+ await handler({ body: {}, params: {}, query: {}, user: { id: 1 }, ...req }, res, (err) => {
+ thrown = err
+ })
+ if (thrown) throw thrown
+ return res
+}
+
+const createDraft = async (over = {}) => call(ctrl.create, { body: draftBody(over) })
+
+// ── The catalog ────────────────────────────────────────────────────────────
+
+test('the catalog serves the registry, callables stripped, with its vocabularies', async () => {
+ const res = await call(ctrl.catalog, {})
+ assert.equal(res.statusCode, 200)
+ assert.deepEqual(
+ res.body.actions.map((a) => a.id),
+ ['core.announce', 'core.wait', 'core.cue'],
+ )
+ for (const action of res.body.actions) assert.equal(action.perform, undefined)
+ assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
+ assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
+ // Phase 1 is honest about what it does not have: budget dimensions arrive with
+ // the module contract, so the catalog does not pretend to carry any.
+ assert.equal(res.body.budgets, undefined)
+})
+
+// ── Create, edit, slug ─────────────────────────────────────────────────────
+
+test('a draft is created with a derived slug and no version', async () => {
+ const res = await createDraft()
+ assert.equal(res.statusCode, 201)
+ assert.equal(res.body.event.state, 'draft')
+ assert.equal(res.body.event.slug, 'the-siege-of-cove')
+ assert.equal(res.body.event.currentVersionId, null)
+ assert.equal(res.body.event.timezone, 'Europe/Berlin')
+})
+
+test('the slug is frozen after create — a retitle does not move the public page', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ const res = await call(ctrl.update, {
+ params: { id: String(id) },
+ body: draftBody({ title: 'The Second Siege of Cove' }),
+ })
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.body.event.title, 'The Second Siege of Cove')
+ assert.equal(res.body.event.slug, 'the-siege-of-cove')
+})
+
+test('a bad timezone and a bad grace window are refused with both problems named', async () => {
+ const res = await createDraft({ timezone: 'Middle/Earth', graceSeconds: 5 })
+ assert.equal(res.statusCode, 400)
+ const joined = res.body.errors.join('\n')
+ assert.match(joined, /not an IANA zone name/)
+ assert.match(joined, /graceSeconds must be an integer/)
+})
+
+test('a step naming an unregistered action is refused at create', async () => {
+ const res = await createDraft({
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'ghost.verb.do' }] }],
+ },
+ })
+ assert.equal(res.statusCode, 400)
+ assert.match(res.body.errors.join('\n'), /no module registers "ghost\.verb\.do"/)
+})
+
+// ── Publish ────────────────────────────────────────────────────────────────
+
+test('publish snapshots the spec, and a later edit does not touch the version', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+
+ const published = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(published.statusCode, 200)
+ assert.equal(published.body.version, 1)
+ assert.equal(published.body.event.state, 'ready')
+
+ // Edit the working copy afterwards.
+ await call(ctrl.update, {
+ params: { id: String(id) },
+ body: draftBody({
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [{ key: 'main', label: 'Main', steps: [announceStep('Something else entirely.')] }],
+ },
+ }),
+ })
+
+ const version = await versionsDb.getById(published.body.versionId)
+ assert.equal(version.spec.phases[0].steps[0].params.body, 'The gates open at dusk.')
+
+ // …and publishing again cuts version 2 rather than mutating version 1.
+ const again = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(again.body.version, 2)
+ const versions = await call(ctrl.listVersions, { params: { id: String(id) } })
+ assert.deepEqual(versions.body.versions.map((v) => v.version), [2, 1])
+ assert.deepEqual(versions.body.versions.map((v) => v.current), [true, false])
+})
+
+test('publish is refused when a step went dormant after the save that wrote it', async () => {
+ registerDemoModule()
+ const created = await createDraft({
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [
+ { key: 'main', label: 'Main', steps: [{ actionId: 'demo.world.change', params: { region: 'Yew' } }] },
+ ],
+ },
+ })
+ const id = created.body.event.id
+
+ // The module is uninstalled between the save and the publish.
+ registries._reset()
+ registries.registerCore()
+
+ const res = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(res.statusCode, 409)
+ assert.match(res.body.errors.join('\n'), /no module registers demo\.world\.change/)
+
+ // …and the definition is still editable, which is the other half of the rule:
+ // an uninstall must not be destructive after the fact.
+ const saved = await call(ctrl.update, { params: { id: String(id) }, body: { title: 'Renamed' } })
+ assert.equal(saved.statusCode, 200)
+ assert.equal(saved.body.event.spec.phases[0].steps[0].dormant, true)
+})
+
+test('an event with no steps does not publish', async () => {
+ const created = await createDraft({
+ spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
+ })
+ const res = await call(ctrl.publish, { params: { id: String(created.body.event.id) } })
+ assert.equal(res.statusCode, 400)
+ assert.match(res.body.errors.join('\n'), /no phase has any steps/)
+})
+
+// ── Runs ───────────────────────────────────────────────────────────────────
+
+test('a draft has nothing to run', async () => {
+ const created = await createDraft()
+ const res = await call(ctrl.startRun, { params: { id: String(created.body.event.id) } })
+ assert.equal(res.statusCode, 409)
+ assert.match(res.body.errors.join('\n'), /no published version to run/)
+})
+
+test('a created run stays scheduled, with its first phase materialised', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+
+ const started = await call(ctrl.startRun, { params: { id: String(id) } })
+ assert.equal(started.statusCode, 201)
+ assert.equal(started.body.created, true)
+ // The correct state for this phase, and it has to be visible as such rather
+ // than looking like a stall: there is no runner until Phase 2.
+ assert.equal(started.body.run.status, 'scheduled')
+
+ const detail = await call(ctrl.getRun, { params: { runId: String(started.body.run.id) } })
+ assert.equal(detail.body.steps.length, 1)
+ assert.equal(detail.body.steps[0].actionId, 'core.announce')
+ assert.equal(detail.body.steps[0].status, 'pending')
+ assert.deepEqual(detail.body.counts, { pending: 1 })
+ // Minted at materialisation, 40 hex, and a function of identity alone.
+ assert.match(detail.body.steps[0].idempotencyKey, /^[0-9a-f]{40}$/)
+})
+
+test('one occurrence, asked for twice, is one run', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+
+ const when = '2026-10-31T20:00:00.000Z'
+ const first = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } })
+ const second = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when } })
+
+ assert.equal(first.statusCode, 201)
+ assert.equal(first.body.created, true)
+ // Not an error — the unique index doing exactly what it is for. The existing
+ // row is the answer.
+ assert.equal(second.statusCode, 200)
+ assert.equal(second.body.created, false)
+ assert.equal(second.body.run.id, first.body.run.id)
+
+ const runs = await call(ctrl.listRuns, { query: { definitionId: String(id) } })
+ assert.equal(runs.body.runs.length, 1)
+})
+
+test('the same instant in two scopes is two runs', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+
+ const when = '2026-10-31T20:00:00.000Z'
+ const a = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'europa' } })
+ const b = await call(ctrl.startRun, { params: { id: String(id) }, body: { scheduledFor: when, scope: 'atlantic' } })
+ assert.equal(a.body.created, true)
+ assert.equal(b.body.created, true)
+ assert.notEqual(a.body.run.id, b.body.run.id)
+})
+
+test('the concurrency key is rendered from the run params', async () => {
+ const created = await createDraft({ concurrencyKey: 'invasion:{region}' })
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+ const res = await call(ctrl.startRun, {
+ params: { id: String(id) },
+ body: { params: { region: 'Yew' } },
+ })
+ assert.equal(res.body.run.concurrencyKey, 'invasion:Yew')
+})
+
+test('an unrendered placeholder is left standing rather than emptied', async () => {
+ // `invasion:` would collide with every other unrendered key on the deployment,
+ // which is the opposite of what a concurrency key is for.
+ const created = await createDraft({ concurrencyKey: 'invasion:{region}' })
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+ const res = await call(ctrl.startRun, { params: { id: String(id) } })
+ assert.equal(res.body.run.concurrencyKey, 'invasion:{region}')
+})
+
+test('the run log records the creation and the phase entry', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+ const started = await call(ctrl.startRun, { params: { id: String(id) } })
+
+ const res = await call(ctrl.getRunLog, { params: { runId: String(started.body.run.id) } })
+ const kinds = res.body.log.map((l) => l.kind)
+ assert.ok(kinds.includes('run.created'))
+ assert.ok(kinds.includes('phase.entered'))
+})
+
+test('an unknown log kind is refused rather than stored', async () => {
+ // The closed set is enforced in the db layer, not by an ENUM, because it grows
+ // with almost every later phase — so it has to actually refuse.
+ restoreOriginals()
+ const ok = await logDb.write({ runId: 1, kind: 'not.a.kind' })
+ assert.equal(ok, false)
+ installStubs()
+})
+
+// ── Archive ────────────────────────────────────────────────────────────────
+
+test('archiving is refused while a run is in flight, and allowed once it is not', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+ const started = await call(ctrl.startRun, { params: { id: String(id) } })
+
+ const refused = await call(ctrl.archive, { params: { id: String(id) } })
+ assert.equal(refused.statusCode, 409)
+ assert.match(refused.body.errors.join('\n'), /still in flight/)
+
+ store.runs.get(started.body.run.id).status = 'completed'
+
+ const res = await call(ctrl.archive, { params: { id: String(id) } })
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.body.event.state, 'archived')
+})
+
+test('an archived definition can be neither edited nor published', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.archive, { params: { id: String(id) } })
+
+ const edited = await call(ctrl.update, { params: { id: String(id) }, body: draftBody() })
+ assert.equal(edited.statusCode, 409)
+ const published = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(published.statusCode, 409)
+})
+
+test('the list filters by state, and an unknown id is 404 rather than 500', async () => {
+ await createDraft()
+ const second = await createDraft({ title: 'A Second Event' })
+ await call(ctrl.publish, { params: { id: String(second.body.event.id) } })
+
+ const ready = await call(ctrl.list, { query: { state: 'ready' } })
+ assert.deepEqual(ready.body.events.map((e) => e.title), ['A Second Event'])
+
+ const missing = await call(ctrl.get, { params: { id: '9999' } })
+ assert.equal(missing.statusCode, 404)
+ const bad = await call(ctrl.get, { params: { id: 'not-a-number' } })
+ assert.equal(bad.statusCode, 400)
+})
--
2.49.1
From 2e964cfeee14e7972b3ece8818c601fb35de44f0 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 2 Sep 2026 06:32:24 -0500
Subject: [PATCH 02/18] feat(events): the runner (Phase 2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`utils/eventRunner.js`, the eighth poller, wired into server.js beside
engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their
grace window into `missed`, advances each due run through its phases, and drains
that phase's steps in `seq` order. The three core actions from Phase 1 get real
bodies, so a published event started from the existing run route now announces,
waits and completes on its own.
No routes are added: a runner has no surface, and the live controls stay Phase
3's.
Four things the org lead settled (2026-09-02): a parked step is `running` with a
NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members
rather than special cases keyed on an action id; a run whose concurrency key is
held stays `scheduled` and lets its grace window decide; and `n` in §L's
`retry(n)` is a runner constant.
Co-Authored-By: Claude
---
server/src/config/coreEventActions.js | 117 +++-
server/src/events/dispatch.js | 166 +++++
server/src/model/events/eventRunLog.db.js | 41 +-
server/src/model/events/eventRunSteps.db.js | 199 +++++-
server/src/model/events/eventRuns.db.js | 260 +++++++-
server/src/server.js | 7 +
server/src/utils/eventRunner.js | 510 +++++++++++++++
server/test/eventActionRegistry.test.js | 71 +-
server/test/eventRunner.test.js | 689 ++++++++++++++++++++
server/test/eventRunnerSql.test.js | 508 +++++++++++++++
10 files changed, 2527 insertions(+), 41 deletions(-)
create mode 100644 server/src/events/dispatch.js
create mode 100644 server/src/utils/eventRunner.js
create mode 100644 server/test/eventRunner.test.js
create mode 100644 server/test/eventRunnerSql.test.js
diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js
index d26eb21..cb6adc0 100644
--- a/server/src/config/coreEventActions.js
+++ b/server/src/config/coreEventActions.js
@@ -13,28 +13,20 @@
// a human to go and do something. A deployment with no game module installed has
// a working event system made of exactly these.
//
-// **Nothing here dispatches yet.** Phase 1 builds the registry, the id grammar,
-// the risk classes and the param validation; Phase 2 builds `utils/eventRunner.js`
-// and is what calls `perform()`. The bodies below therefore answer with the
-// envelope §F defines for a refusal — and specifically NOT with `{ ok: true }`,
-// which is the one wrong answer a placeholder can give: `ok: true` on an action
-// that did nothing is a recorded world change that did not occur, which is the
-// exact mistake the envelope's failure default exists to prevent. `retry: false`
-// because a missing runner is not a transient condition.
+// **Phase 2 gave all three real bodies**, and between them they exercise every
+// shape §F's envelope can take: `core.announce` does work and finishes,
+// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
+// without finishing at all. The runner learns nothing about any of them by id —
+// each says what it needs in the envelope, through the same two members Phase 7
+// hands to a module.
//
// **This file must not touch the database.** It is required from `registerCore()`,
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
-// (MODULE_API.md §2.2). It is pure data plus three functions that are not called.
+// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
+// looked up inside `perform()`, per call, which is also what makes a leg
+// registered by a module that booted later reachable at all.
-// A factory rather than one shared function, because `perform`'s argument is
-// §F's dispatch envelope — `{ runId, stepId, idempotencyKey, scope, params,
-// actor, verify }` — and it does not carry the action's own id. Closing over it
-// is what lets the refusal name which action refused.
-const notWiredYet = (actionId) => async () => ({
- ok: false,
- retry: false,
- error: `${actionId} is declared in Phase 1 and dispatched from Phase 2`,
-})
+const registries = require('../modules/registries')
const ACTIONS = [
{
@@ -81,7 +73,52 @@ const ACTIONS = [
},
],
- perform: notWiredYet('core.announce'),
+ /**
+ * Publish through the announce leg the step names.
+ *
+ * **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
+ * `discord` is core's and `towncrier` is module-uo's, both already registered,
+ * both already carrying a `classify()` that knows what their transport's
+ * failures mean. An event announcement that went out by some other path would
+ * be a second delivery mechanism with its own bugs.
+ *
+ * A leg's `dispatch()` takes a POST — that is the shape the news path gave it
+ * — so an event announcement is presented as one. `excerpt` is the body
+ * because it is the field every leg renders as prose, and `image_url` is null
+ * because an event announcement has no article behind it to illustrate.
+ * Widening the leg contract to carry a second payload shape is a
+ * MODULE_API change, and Phase 7 is where those are made.
+ *
+ * The leg id is checked HERE rather than at authoring time, and that is not
+ * laxness: legs are registered by modules, and a spec is validated in a
+ * process that may have booted before the module that owns the leg.
+ */
+ async perform({ params, verify }) {
+ const registered = registries.announceLeg(params.leg)
+ if (!registered) {
+ // Terminal, not transient. A leg nobody registers will not appear
+ // between two attempts sixty seconds apart, and the honest cause — a
+ // module removed, or a typo the authoring form could not catch — is a
+ // thing a human fixes.
+ return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
+ }
+ // A dry run reports what it WOULD do and sends nothing (§I). Answering
+ // before the dispatch rather than inside the leg is what keeps that true
+ // for legs written by people who never read this file.
+ if (verify) return { ok: true }
+
+ const result = await registered.dispatch({
+ title: params.title || null,
+ excerpt: params.body,
+ image_url: null,
+ })
+ // The leg's own classification, not a second opinion. `retry` vs
+ // `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
+ // already makes, and making it twice is how the two drift.
+ const { outcome, error } = registered.classify(result)
+ if (outcome === 'done') return { ok: true }
+ return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
+ },
},
{
@@ -105,11 +142,18 @@ const ACTIONS = [
},
],
- // A wait is a genuine no-op at dispatch, and it will stay one: the delay is
- // the NEXT step's `due_at`, which the runner owns, not something this
- // function sleeps through. A `perform` that slept would hold a step's claim
- // for the duration and turn a five-minute pause into a five-minute lease.
- perform: notWiredYet('core.wait'),
+ // A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
+ // NEXT step's `due_at`, which the runner owns, not something this function
+ // sleeps through. A `perform` that slept would hold a step's claim for the
+ // duration and turn a five-minute pause into a five-minute lease — and the
+ // reclaim would then re-dispatch it, so a long enough wait would never end.
+ //
+ // `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
+ // why the runner can honour this without knowing what `core.wait` is.
+ async perform({ params, verify }) {
+ if (verify) return { ok: true }
+ return { ok: true, holdFor: params.seconds }
+ },
},
{
@@ -142,11 +186,26 @@ const ACTIONS = [
},
],
- // Phase 2 gives this its parking semantics — a cue step does not complete
- // when `perform` answers, it completes when a human presses confirm, and the
- // control that does so is Phase 3's. Both of those are what make this the
- // one action whose runtime shape is deliberately not decided here.
- perform: notWiredYet('core.cue'),
+ /**
+ * Post the instruction and PARK. The step does not complete here.
+ *
+ * `await: 'human'` is the envelope member that says so (org lead,
+ * 2026-09-02), and the runner's answer to it is to leave the step `running`
+ * with a NULL lease — genuinely in flight, nothing holding it, so the stale
+ * reclaim passes it by and a cue posted on Friday is still waiting on Monday.
+ * The step ends when someone presses confirm, which is Phase 3's control.
+ *
+ * **Nothing is delivered from here in Phase 2, and that is visible rather
+ * than pretended.** The instruction is carried by the step's own params and
+ * shown on the run console; routing it to Discord or to a staff inbox is
+ * Phase 10's integration work, through the engagement triggers that own every
+ * other notification on this platform. An action that grew its own delivery
+ * path would be the second one.
+ */
+ async perform({ verify }) {
+ if (verify) return { ok: true }
+ return { ok: true, await: 'human' }
+ },
},
]
diff --git a/server/src/events/dispatch.js b/server/src/events/dispatch.js
new file mode 100644
index 0000000..3ac9e32
--- /dev/null
+++ b/server/src/events/dispatch.js
@@ -0,0 +1,166 @@
+// ── Dispatching one step to one action ─────────────────────────────────────
+//
+// EVENTS.md §F. This is the boundary between the runner and code core did not
+// write, and it exists as its own file because it has exactly one job: call
+// `perform()` and turn whatever comes back — an envelope, a lie, a throw, a
+// promise that never settles — into one of four classifications the runner knows
+// how to act on.
+//
+// **§F's load-bearing rule, and the reason none of this is inlined into the
+// runner: no shape a failure can take may read as success.** A rejected promise,
+// a throw, a timeout, a non-object and a missing `ok` are all
+// `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s
+// default, deliberately — a team provider that refuses leaves core showing what
+// it already had, because staleness is cheap, whereas an action that half-ran and
+// was recorded as `done` is a world change nothing will ever come back for.
+//
+// **The timeout is the module contract's, not this file's opinion.** Every action
+// declares `budgetMs` at registration and the registry bounds it there; here it
+// is enforced. Without it a module whose `perform()` awaits a socket that never
+// answers holds a step's claim until the lease expires, and the reclaim then
+// re-dispatches it — which is how one wedged sidecar becomes an infinite loop
+// rather than a failed step.
+
+const registries = require('../modules/registries')
+const log = require('../utils/logger')('events')
+
+// What a classification can be. `parked` is Phase 2's addition and it is the one
+// outcome that is neither terminal nor a retry: the action succeeded, and the
+// step is not finished, because something outside this system has to happen next.
+const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
+
+// The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction,
+// not a lease, so this is generous — but it is bounded, because an action that
+// answers `holdFor: 1e9` would park the phase past the heat death of the shard
+// and the step that did it would look, in the console, exactly like one that
+// worked.
+const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
+
+/**
+ * Run `fn()` under a deadline.
+ *
+ * The loser of the race is not cancelled — JavaScript has no such thing, and a
+ * `perform()` still awaiting a socket keeps awaiting it. What the deadline buys
+ * is that the RUNNER stops waiting, which is the half that matters: the step is
+ * classified, the claim is released, and the tick moves on. A late answer from
+ * the abandoned call lands on a step that has already been written, and the
+ * idempotency key is what makes the retry that follows safe on the game side.
+ */
+function withDeadline(fn, ms, actionId) {
+ let timer = null
+ const deadline = new Promise((resolve) => {
+ timer = setTimeout(
+ () => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }),
+ ms,
+ )
+ if (timer.unref) timer.unref()
+ })
+ return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => {
+ if (timer) clearTimeout(timer)
+ })
+}
+
+/**
+ * Turn a raw `perform()` answer into `{ outcome, error?, holdSeconds?, resources? }`.
+ *
+ * Exported and pure, so the classification rules are testable without a registry,
+ * a database or a clock — which matters because they are the rules that decide
+ * whether a world change is recorded as having happened.
+ */
+function classify(result, actionId) {
+ if (result && result.__timedOut) {
+ // Transient by default: a timeout says nothing about whether the action ran.
+ // That ambiguity is exactly what the idempotency key exists to resolve, and
+ // resolving it on the game side is Phase 11's protocol work — until then a
+ // retry is the honest choice and the risk class decides what happens when the
+ // retries run out.
+ return { outcome: 'retry', error: result.error }
+ }
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
+ return { outcome: 'retry', error: `${actionId} answered with no envelope` }
+ }
+ if (result.ok !== true) {
+ // `retry` must be opted into. An action that means "this will never work"
+ // says `retry: false`, and an envelope that forgot to say anything gets the
+ // benefit of the doubt on the transient question but not on the success one.
+ const retry = result.retry !== false
+ return {
+ outcome: retry ? 'retry' : 'terminal',
+ error: result.error ? String(result.error) : `${actionId} refused`,
+ }
+ }
+
+ // ── The two success shapes that are not "finished" ──
+ //
+ // Both were settled by the org lead on 2026-09-02, and both are envelope
+ // members rather than special cases keyed on an action id, so that the runner
+ // never names a verb. `core.cue` and `core.wait` reach them through the same
+ // door Phase 7 opens to a module's own long-running action.
+ if (result.await === 'human') {
+ return { outcome: 'parked', error: null, resources: result.resources || [] }
+ }
+
+ let holdSeconds = 0
+ if (result.holdFor !== undefined && result.holdFor !== null) {
+ const n = Number(result.holdFor)
+ if (!Number.isFinite(n) || n < 0) {
+ return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` }
+ }
+ holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
+ }
+
+ return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] }
+}
+
+/**
+ * Dispatch one step. Never throws.
+ *
+ * `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's
+ * route): `verify === true` means validate and report, change nothing. It is
+ * passed from here rather than being a separate code path so that the dry run
+ * exercises the real dispatcher — a dry run down a second path is a dry run of
+ * the second path.
+ */
+async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
+ const action = registries.eventAction(step.action_id)
+ if (!action) {
+ // §L, verbatim: "a step naming one fails terminal with the module named, and
+ // the run degrades rather than claiming success. Never a silent skip." The
+ // module was uninstalled or failed to boot between publish and now — publish
+ // refuses a dormant step, so this cannot be an authoring mistake.
+ return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true }
+ }
+
+ const envelope = {
+ runId: run.id,
+ stepId: step.id,
+ idempotencyKey: step.idempotency_key,
+ scope: run.scope || '',
+ params: step.params || {},
+ actor,
+ verify: Boolean(verify),
+ }
+
+ let raw
+ try {
+ raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id)
+ } catch (err) {
+ // A module should not throw, and if one does it is a transient failure rather
+ // than a crashed tick — announceWorker's posture with its legs, and the
+ // reason one bad module cannot stop every other run on the deployment.
+ log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message })
+ return { outcome: 'retry', error: err.message }
+ }
+
+ const classification = classify(raw, action.id)
+ if (step.action_version && action.version !== step.action_version) {
+ // Not a refusal: the step was authored against an older declaration and the
+ // module has moved on. The editor is where that becomes a warning (§F); here
+ // it is recorded, so a run that behaved oddly can be explained afterwards by
+ // reading the log rather than by guessing.
+ classification.actionVersionDrift = { authored: step.action_version, registered: action.version }
+ }
+ return classification
+}
+
+module.exports = { dispatchStep, classify, withDeadline, OUTCOMES, MAX_HOLD_SECONDS }
diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js
index a32732c..8f34d87 100644
--- a/server/src/model/events/eventRunLog.db.js
+++ b/server/src/model/events/eventRunLog.db.js
@@ -23,6 +23,16 @@ const KINDS = [
'phase.entered', // a phase's steps were materialised
'step.status', // a step transition, with the module's answer
'note', // a human action taken from the admin surface
+ // Phase 2's, all five of them answers to a question an operator asks out
+ // loud. `run.blocked` in particular is the whole reason this table exists
+ // rather than a server log line: "it did not start because run 37 holds
+ // invasion:Yew" is a fact with two run ids in it, and it has to be
+ // queryable from the run that did NOT start.
+ 'run.blocked', // an occurrence held off: another run has its concurrency key
+ 'run.health', // a health change, which is not a status change
+ 'step.retry', // a step failed transiently and will be attempted again
+ 'step.parked', // a step is waiting on a human and nothing is holding it
+ 'phase.completed', // every step of a phase reached a terminal status
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
@@ -64,4 +74,33 @@ async function write({ runId, stepId = null, kind, phase = null, detail = null }
}
}
-module.exports = { KINDS, listForRun, write }
+/**
+ * Delete log lines belonging to runs that are both TERMINAL and older than
+ * `before`, a bounded number at a time.
+ *
+ * The schema comment beside `idx_evlog_at` parked this sweep here, and it is the
+ * rule Engagement Phase 14 arrived at applied to a second high-cardinality table:
+ * **only terminal rows are eligible.** A run still in flight keeps every line it
+ * has, however old — the log's whole job is answering "why didn't phase 3 start?"
+ * about a run that is, right now, not starting phase 3, and a horizon that could
+ * reach a live run would delete the answer while the question was still open.
+ *
+ * `LIMIT` makes one call a bounded amount of work rather than a table-sized
+ * transaction; the timer runs again and takes the next slice. The join is on the
+ * run's terminal status rather than on a precomputed id list so that a run which
+ * reached a terminal state between the two would not be missed.
+ */
+const pruneTerminal = async (before, limit = 5000) => {
+ const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000)
+ const result = await query(
+ `DELETE l FROM event_run_log l
+ JOIN event_runs r ON r.id = l.run_id
+ WHERE r.status IN ('completed','cancelled','failed','missed')
+ AND COALESCE(r.ended_at, r.updated_at) < ?
+ LIMIT ${n}`,
+ [before],
+ )
+ return Number(result?.affectedRows || 0)
+}
+
+module.exports = { KINDS, listForRun, write, pruneTerminal }
diff --git a/server/src/model/events/eventRunSteps.db.js b/server/src/model/events/eventRunSteps.db.js
index bf0a7dc..d9d4041 100644
--- a/server/src/model/events/eventRunSteps.db.js
+++ b/server/src/model/events/eventRunSteps.db.js
@@ -92,4 +92,201 @@ const statusCounts = async (runId) => {
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]))
}
-module.exports = { listForRun, getById, materialisePhase, statusCounts, idempotencyKey }
+// ── Phase 2: draining a step ───────────────────────────────────────────────
+//
+// The CAS claim, the lease, the attempt counter and the terminal writes. Phase 1
+// left all of it out rather than stubbing it, and this is where it lands.
+//
+// **Two rules govern everything below, and both were paid for once already.**
+//
+// 1. `attempts` is incremented by the CLAIM and by nothing else, and no recovery
+// path ever resets it. Engagement Phase 14's defect was a stale-row sweep that
+// returned rows to their start state: the attempt ceiling became unreachable,
+// so the row cycled forever, never reached a terminal status, and was
+// therefore never eligible for any retention sweep.
+// 2. A PARKED step is `running` with a NULL lease, and the reclaim only ever
+// touches a lease that is non-NULL and expired (the org lead's answer,
+// 2026-09-02). That is what lets a GM cue wait for a human overnight without a
+// sweep re-dispatching the instruction every fifteen minutes.
+
+// A run's steps in authored order, for the phase the run is currently in.
+const listForPhase = async (runId, phase) =>
+ (
+ await query(
+ 'SELECT * FROM event_run_steps WHERE run_id = ? AND phase = ? ORDER BY seq, id',
+ [runId, phase],
+ )
+ ).map(hydrate)
+
+/**
+ * The next step of a phase that the runner may work on, or null.
+ *
+ * **Steps within a phase are strictly serial.** This returns the lowest-`seq`
+ * step that is not terminal, and the runner does nothing with step N+1 until N
+ * has finished — which is the only reading under which `core.wait` means anything
+ * at all, and the only one under which a cue can gate what follows it.
+ *
+ * A parked or running step is returned too, so the caller can see that the phase
+ * is occupied rather than concluding it is finished.
+ */
+const nextOpenStep = async (runId, phase) => {
+ const [row] = await query(
+ `SELECT * FROM event_run_steps
+ WHERE run_id = ? AND phase = ?
+ AND status IN ('pending','running')
+ ORDER BY seq, id LIMIT 1`,
+ [runId, phase],
+ )
+ return hydrate(row) || null
+}
+
+/**
+ * Take ownership of one pending step: the CAS `pending -> running`, plus a lease.
+ *
+ * `due_at` is honoured here rather than in the caller's filter so that the whole
+ * decision — is it mine, is it due — is one statement the database arbitrates. A
+ * NULL `due_at` is due now, which is what materialisation writes for every step
+ * that is not sitting behind a `core.wait`.
+ */
+async function claim(id, owner, leaseUntil, now) {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?,
+ started_at = COALESCE(started_at, NOW())
+ WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`,
+ [owner, leaseUntil, id, now],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Park a claimed step: it stays `running`, and its lease goes NULL.
+ *
+ * This is the whole mechanism behind `core.cue`. The step is genuinely in flight
+ * — an instruction has been posted and nothing else in the phase may proceed —
+ * but no process is holding it, so the reclaim must not take it back. A NULL
+ * lease says exactly that, and `reclaimStale` below is written to agree.
+ */
+const park = (id, note) =>
+ query(
+ `UPDATE event_run_steps SET claim_expires_at = NULL, last_error = ?
+ WHERE id = ? AND status = 'running'`,
+ [note ? String(note).slice(0, 500) : null, id],
+ )
+
+/**
+ * Release a claimed step back to `pending` for a later attempt.
+ *
+ * `attempts` is untouched — it was already incremented by the claim, which is the
+ * only place that may. Backoff is flat rather than exponential for the reason the
+ * outbox's is: `due_at` is also the event's own clock, and a doubling backoff
+ * pushes a step arbitrarily far past the moment the event was about.
+ */
+const reschedule = (id, dueAt, error) =>
+ query(
+ `UPDATE event_run_steps
+ SET status = 'pending', due_at = ?, claimed_by = NULL, claim_expires_at = NULL, last_error = ?
+ WHERE id = ? AND status = 'running'`,
+ [dueAt, error ? String(error).slice(0, 500) : null, id],
+ )
+
+/** A terminal outcome for one step: done, failed, skipped, refused or cancelled. */
+const finish = (id, status, error) =>
+ query(
+ `UPDATE event_run_steps
+ SET status = ?, last_error = ?, finished_at = NOW(),
+ claimed_by = NULL, claim_expires_at = NULL
+ WHERE id = ? AND status = 'running'`,
+ [status, error ? String(error).slice(0, 500) : null, id],
+ )
+
+/**
+ * Delay the next not-yet-started step of a phase — what `core.wait` actually does.
+ *
+ * The wait step itself completes normally; the pause is the NEXT step's `due_at`,
+ * owned by the runner. A `perform()` that slept would hold its claim for the
+ * duration and turn a five-minute pause into a five-minute lease, which is the
+ * one shape this must not have.
+ *
+ * Guarded on `status = 'pending'` and on the current `due_at` being sooner, so a
+ * re-dispatch of a wait whose ack was lost cannot push the following step further
+ * out a second time.
+ */
+const holdNext = async (runId, phase, afterSeq, dueAt) => {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET due_at = ?
+ WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending'
+ AND (due_at IS NULL OR due_at < ?)
+ ORDER BY seq LIMIT 1`,
+ [dueAt, runId, phase, afterSeq, dueAt],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Recover steps whose claim outlived the process that took it.
+ *
+ * **`attempts` is not reset and the lease being NULL is not staleness.** The
+ * first is Engagement Phase 14's rule; the second is what makes a parked cue
+ * survive. A step that has already burned its attempts leaves `running` as
+ * `failed` rather than being handed back, and in that order — a reclaim that ran
+ * first would return it to `pending` and it would be retried forever.
+ */
+const reclaimStale = async (now, maxAttempts = 0) => {
+ let failed = 0
+ if (Number(maxAttempts) > 0) {
+ const gaveUp = await query(
+ `UPDATE event_run_steps
+ SET status = 'failed', last_error = 'gave up after repeated interruptions',
+ finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL
+ WHERE status = 'running'
+ AND claim_expires_at IS NOT NULL AND claim_expires_at < ?
+ AND attempts >= ?`,
+ [now, Math.floor(maxAttempts)],
+ )
+ failed = Number(gaveUp?.affectedRows || 0)
+ }
+ const reclaimed = await query(
+ `UPDATE event_run_steps
+ SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL
+ WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`,
+ [now],
+ )
+ return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
+}
+
+/**
+ * Cancel every step of a run that has not started (Phase 3's cancel, and the
+ * abort_run disposition).
+ *
+ * A `running` step is deliberately left alone, parked or not: nothing can recall
+ * a command already sent, and a second writer on that row would race the process
+ * that owns it (§L).
+ */
+const cancelPending = async (runId) => {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET status = 'cancelled', finished_at = NOW()
+ WHERE run_id = ? AND status = 'pending'`,
+ [runId],
+ )
+ return Number(result?.affectedRows || 0)
+}
+
+module.exports = {
+ listForRun,
+ listForPhase,
+ getById,
+ materialisePhase,
+ statusCounts,
+ idempotencyKey,
+ nextOpenStep,
+ claim,
+ park,
+ reschedule,
+ finish,
+ holdNext,
+ reclaimStale,
+ cancelPending,
+}
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index bf8a59f..a9c06fe 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -17,6 +17,12 @@ const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
+// The statuses a run never leaves. A transition INTO one of these stamps
+// `ended_at` and drops the claim, and only rows in one of them are eligible for
+// the log retention sweep -- Engagement Phase 14's rule, which is a bound at all
+// only if every path a run can take reaches one of them.
+const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
+
const SELECT_LIST = `
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number
FROM event_runs r
@@ -102,4 +108,256 @@ const countActiveForDefinition = async (definitionId) => {
return Number(row?.n || 0)
}
-module.exports = { list, getById, materialise, findOccurrence, countActiveForDefinition }
+// ── Phase 2: the claim, the transitions and the reclaim ────────────────────
+//
+// Everything below is the runner's, and none of it existed in Phase 1 for a
+// stated reason: a half-written claim is worse than no claim, because it reads
+// as protection. It is written here now, in full.
+//
+// **The division of labour with the unique index has not changed.** The index one
+// section up is what makes "one run per occurrence per scope" TRUE; the CAS below
+// decides only WHO advances an occurrence that already exists. Neither substitutes
+// for the other, and this deployment being single-instance (§N4) changes the test
+// rather than the design — the same two protections are what keep a tick that
+// overran into the next one from advancing a run twice.
+
+/**
+ * Runs the runner should look at this tick: due, and not yet terminal.
+ *
+ * It selects rather than claims — `claimStart` and `claimTick` below are one row
+ * at a time — so two sweepers see the same candidates and then disagree,
+ * harmlessly, about which of them owns each. `idx_evrun_due (status,
+ * scheduled_for)` is this query.
+ *
+ * `paused` is absent from the status list on purpose. A paused run is waiting on
+ * a human and must not be advanced by a tick; the only thing that moves it is
+ * Phase 3's resume control.
+ */
+const findDue = async (now, limit = 50) => {
+ const n = Math.min(Math.max(Number(limit) || 50, 1), 500)
+ return (
+ await query(
+ `SELECT * FROM event_runs
+ WHERE status IN ('scheduled','starting','running','ending')
+ AND scheduled_for <= ?
+ ORDER BY scheduled_for, id
+ LIMIT ${n}`,
+ [now],
+ )
+ ).map(hydrate)
+}
+
+/**
+ * Take ownership of a run that has not started: the CAS `scheduled -> starting`.
+ *
+ * Verbatim the outbox claim the org lead settled over `SELECT ... FOR UPDATE
+ * SKIP LOCKED` — the instance the server reports `affectedRows = 1` to owns the
+ * row, every other sweeper gets 0 and moves on. No transaction to hold open and
+ * no MariaDB version floor.
+ */
+async function claimStart(id, owner, leaseUntil) {
+ const result = await query(
+ `UPDATE event_runs
+ SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
+ started_at = COALESCE(started_at, NOW())
+ WHERE id = ? AND status = 'scheduled'`,
+ [owner, leaseUntil, id],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Take a lease on a run already in flight, so one tick works on it at a time.
+ *
+ * Unlike `claimStart` this does not change `status` — the run is already
+ * `starting`, `running` or `ending`, and what is being claimed is the right to
+ * advance it.
+ *
+ * **A live lease is not re-enterable, not even by the process that took it**, and
+ * that is the whole point rather than an oversight. `setInterval` fires the next
+ * tick whether or not the last one has returned, so an owner-matches escape
+ * clause here would let one process advance one run twice at once — which is
+ * precisely the overrun the plan says the CAS is meant to protect against. A run
+ * this process still holds is a run this process is still working on; the tick
+ * skips it, and `releaseClaim` below is what ends that in the ordinary case.
+ */
+async function claimTick(id, owner, leaseUntil, now) {
+ const result = await query(
+ `UPDATE event_runs
+ SET claimed_by = ?, claim_expires_at = ?
+ WHERE id = ?
+ AND status IN ('starting','running','ending')
+ AND (claim_expires_at IS NULL OR claim_expires_at < ?)`,
+ [owner, leaseUntil, id, now],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Give a still-in-flight run back, so the next tick can pick it up at once.
+ *
+ * A run left parked on a GM cue, or waiting out a `core.wait`, is not finished
+ * and must not carry a lease: without this the run would be unadvanceable until
+ * the lease expired, which would turn every wait into `max(wait, leaseMs)`.
+ * Scoped to `claimed_by = ?` so a process can only release its own claim.
+ */
+async function releaseClaim(id, owner) {
+ const result = await query(
+ 'UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL WHERE id = ? AND claimed_by = ?',
+ [id, owner],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * A guarded status transition: `from -> to`, and only from `from`.
+ *
+ * Every move the runner makes goes through here rather than through a bare
+ * UPDATE, so "did this transition actually happen" is answerable at each call
+ * site. A `false` is not an error — it is another worker, or this run having been
+ * cancelled from the admin surface between the read and the write, which is a
+ * race Phase 3's live controls make ordinary.
+ */
+async function transition(id, from, to, { phase, error, clearClaim = false } = {}) {
+ const sets = ['status = ?']
+ const args = [to]
+ if (phase !== undefined) {
+ sets.push('current_phase = ?')
+ args.push(phase)
+ }
+ if (error !== undefined) {
+ sets.push('last_error = ?')
+ args.push(error === null ? null : String(error).slice(0, 500))
+ }
+ if (TERMINAL.includes(to)) sets.push('ended_at = COALESCE(ended_at, NOW())')
+ if (clearClaim || TERMINAL.includes(to)) sets.push('claimed_by = NULL', 'claim_expires_at = NULL')
+
+ const froms = Array.isArray(from) ? from : [from]
+ const result = await query(
+ `UPDATE event_runs SET ${sets.join(', ')}
+ WHERE id = ? AND status IN (${froms.map(() => '?').join(',')})`,
+ [...args, id, ...froms],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Set health without touching status (§E).
+ *
+ * The two columns are separate because a run can be genuinely running and
+ * degraded at once — announcements landing, world writes parked — and one column
+ * cannot say both. Guarded on the current value so a tick that re-observes the
+ * same degradation does not restamp `updated_at`.
+ */
+async function setHealth(id, health) {
+ const result = await query('UPDATE event_runs SET health = ? WHERE id = ? AND health <> ?', [
+ health,
+ id,
+ health,
+ ])
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Runs whose start instant passed more than their own grace window ago (§E, §L).
+ *
+ * The window is per definition, so the comparison is against `grace_seconds` on
+ * the joined row rather than against a constant here: an event whose announcement
+ * gives a fifteen-minute window and one that must start on the second are the
+ * same query with different data.
+ *
+ * Only `scheduled` runs qualify. A run that reached `starting` has begun, and
+ * "began and then stalled" is a different fact from "never began" — conflating
+ * them would let `missed` describe a run that had already announced itself.
+ */
+const findMissed = async (now, limit = 100) => {
+ const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
+ return (
+ await query(
+ `SELECT r.* FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ WHERE r.status = 'scheduled'
+ AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?
+ ORDER BY r.scheduled_for
+ LIMIT ${n}`,
+ [now],
+ )
+ ).map(hydrate)
+}
+
+/**
+ * Is another run holding this concurrency key?
+ *
+ * The org lead's answer for a held key (2026-09-02) is to leave the run
+ * `scheduled` and let the grace window decide, so this is a READ rather than a
+ * claim: the caller holds off, logs which run holds the key, and tries again next
+ * tick. `idx_evrun_concurrency (concurrency_key, status)` is this query, and a
+ * NULL key is skipped by that index — which is right, because a definition with
+ * no key never contends.
+ */
+const concurrencyHolder = async (key, exceptRunId) => {
+ if (!key) return null
+ const [row] = await query(
+ `SELECT id, status, definition_id FROM event_runs
+ WHERE concurrency_key = ?
+ AND id <> ?
+ AND status IN ('starting','running','paused','ending')
+ ORDER BY id LIMIT 1`,
+ [key, exceptRunId],
+ )
+ return row || null
+}
+
+/**
+ * Recover runs whose claim outlived the process that took it.
+ *
+ * **It does not change status and it touches no counter.** All it releases is the
+ * lease; the run stays exactly where it was and the next tick picks it up through
+ * `findDue`. This is Engagement Phase 14's lesson applied one table over: a sweep
+ * that returned a stale row to its start state made the attempt ceiling
+ * unreachable, so the row cycled forever, never terminal, and therefore never
+ * eligible for any retention sweep.
+ */
+const reclaimStale = async (now) => {
+ const result = await query(
+ `UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL
+ WHERE status IN ('starting','running','ending')
+ AND claim_expires_at IS NOT NULL
+ AND claim_expires_at < ?`,
+ [now],
+ )
+ return Number(result?.affectedRows || 0)
+}
+
+/** Terminal runs that ended before `before` — what the log retention sweep walks. */
+const terminalBefore = async (before, limit = 500) => {
+ const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
+ return (
+ await query(
+ `SELECT id FROM event_runs
+ WHERE status IN (${TERMINAL.map(() => '?').join(',')})
+ AND COALESCE(ended_at, updated_at) < ?
+ ORDER BY id LIMIT ${n}`,
+ [...TERMINAL, before],
+ )
+ ).map((r) => Number(r.id))
+}
+
+module.exports = {
+ list,
+ getById,
+ materialise,
+ findOccurrence,
+ countActiveForDefinition,
+ findDue,
+ findMissed,
+ claimStart,
+ claimTick,
+ releaseClaim,
+ transition,
+ setHealth,
+ concurrencyHolder,
+ reclaimStale,
+ terminalBefore,
+ TERMINAL,
+}
diff --git a/server/src/server.js b/server/src/server.js
index 7ac2a23..be12168 100644
--- a/server/src/server.js
+++ b/server/src/server.js
@@ -15,6 +15,7 @@ const engagementRetentionPrune = require('./utils/engagementRetentionPrune')
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
const teamDigestWorker = require('./utils/teamDigestWorker')
const engagementWorker = require('./utils/engagementWorker')
+const eventRunner = require('./utils/eventRunner')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -169,6 +170,11 @@ async function start() {
// enables a rule: core seeds none and `enabled` defaults to 0.
engagementWorker.start()
+ // Advance scheduled events (EVENTS.md §E). Materialise, advance, drain, and the
+ // run-log retention sweep. No-op until an admin publishes a definition and
+ // starts a run: core ships no event definitions.
+ eventRunner.start()
+
setupShutdown(server, internalServer)
}
@@ -192,6 +198,7 @@ function setupShutdown(server, internalServer) {
teamForumUploadSweep.stop() // stop the forum upload sweep
teamDigestWorker.stop() // stop the Team forum digest timer
engagementWorker.stop() // stop the engagement outbox worker
+ eventRunner.stop() // stop the event runner
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {
diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js
new file mode 100644
index 0000000..e645496
--- /dev/null
+++ b/server/src/utils/eventRunner.js
@@ -0,0 +1,510 @@
+// ── The event runner ───────────────────────────────────────────────────────
+//
+// EVENTS.md §E, and Phase 2 of EVENTS_PLAN.md. The eighth poller: same
+// `setInterval` + `unref()` + `stop()` shape as `announceWorker`, the three Team
+// sweepers and `engagementWorker`, wired into `server.js` beside them. In the
+// **website** process rather than the bot, which cannot load module code.
+//
+// Its tick does four things, in this order:
+//
+// 1. **reclaim** — release leases whose holder died, never touching `attempts`
+// 2. **materialise** — sweep occurrences past their grace window into `missed`
+// 3. **advance** — claim each due run and move it through its phases
+// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
+//
+// **What "materialise" means in this phase.** §E's tick materialises due
+// occurrences from a recurrence; the spec validator accepts `kind: 'manual'`
+// alone until Phase 4, so there is no recurrence to expand and the only
+// occurrences that exist are the ones an admin created. What that leaves for this
+// leg is the half that is already real and already needed: the grace window. A
+// run whose instant passed while the process was down does not start late and
+// silently — it becomes `missed`, which is terminal and which a human can see
+// (§L). Phase 4 adds the expansion above it.
+//
+// **Two properties this file must not lose**, both already paid for once on this
+// codebase:
+//
+// - **A reclaim never resets `attempts`.** Engagement Phase 14's defect: a sweep
+// that returned every stale row to its start state made the attempt ceiling
+// unreachable, so the row cycled forever, never terminal, and therefore never
+// eligible for any retention sweep.
+// - **The unique index, not the claim, is what prevents a double run.** The claim
+// decides *who* advances an occurrence; `uq_evrun_occurrence` is what stops two
+// of them existing. Neither substitutes for the other.
+//
+// §N4 settled this deployment as single-instance, so the `--scale app=2` rig the
+// engagement workstream used is deliberately not built. Every claim path is here
+// exactly as §E specifies anyway, because the multi-instance case is not what
+// they are for on a single container: the CAS is what protects a tick that
+// overran into the next one, and the lease and its reclaim are what recover a
+// step whose process died mid-dispatch. Both happen with one app.
+
+const os = require('os')
+
+const runsDb = require('../model/events/eventRuns.db')
+const stepsDb = require('../model/events/eventRunSteps.db')
+const logDb = require('../model/events/eventRunLog.db')
+const versionsDb = require('../model/events/eventVersions.db')
+const registries = require('../modules/registries')
+const { dispatchStep } = require('../events/dispatch')
+const log = require('./logger')('event-runner')
+
+const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
+
+// How many runs one sweep looks at, and how many steps it will drain from one
+// run. Bounds rather than targets: the tick runs again in POLL_MS, and an
+// unbounded batch is how a backlog turns one tick into a stall. The step bound
+// also caps how long one run can hold the tick, which is what keeps a
+// forty-step phase from starving every other run on the deployment.
+const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50
+const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25
+
+// A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip |
+// pause | abort_run` and names no `n`; it lives here, as one number an operator
+// can change, rather than in a column no authoring surface would ever show.
+//
+// Flat backoff rather than exponential, for the reason the outbox's is flat:
+// `due_at` is also the event's own clock, and a doubling backoff pushes a step
+// arbitrarily far past the moment the event was about.
+const MAX_ATTEMPTS = Number(process.env.EVENT_STEP_MAX_ATTEMPTS) || 3
+const RETRY_MS = Number(process.env.EVENT_STEP_RETRY_MS) || 60_000
+
+// How long a claim may look alive before the reclaim takes it back. The run
+// lease has to outlast a whole tick's work on one run; a step's is computed from
+// its own action's `budgetMs` (see `leaseFor`), because a registry that lets an
+// action declare an hour would otherwise have its steps reclaimed and
+// re-dispatched fifty-nine minutes before they answered.
+const RUN_LEASE_MS = Number(process.env.EVENT_RUN_LEASE_MS) || 15 * 60 * 1000
+const STEP_LEASE_MARGIN_MS = 60_000
+
+// The log retention horizon, and how often the sweep runs. It is folded into
+// this tick rather than given a ninth timer because it shares the tick's only
+// real dependency — a database — and a sweep that runs four times a day does not
+// need an interval of its own. Only TERMINAL runs are ever eligible, which is the
+// rule Engagement Phase 14 arrived at.
+const LOG_RETENTION_DAYS = Number(process.env.EVENT_LOG_RETENTION_DAYS) || 90
+const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000
+
+// Who this process is, for `claimed_by`. Host and pid, so a stranded claim in the
+// table names the thing that stranded it.
+const OWNER = `${os.hostname()}:${process.pid}`.slice(0, 64)
+
+const RUN_TERMINAL = runsDb.TERMINAL
+
+/** The lease a step's dispatch gets: its action's own budget, plus a margin. */
+function leaseFor(step, now) {
+ const action = registries.eventAction(step.action_id)
+ const budget = action?.budgetMs || 10_000
+ return new Date(now.getTime() + budget + STEP_LEASE_MARGIN_MS)
+}
+
+// ── The disposition of a step that has run out of road ─────────────────────
+//
+// **All three dispositions write the step `failed`.** `on_failure` says what
+// happens to the RUN, not what happened to the step, and a step that was
+// attempted three times and never worked is `failed` under every one of them.
+// `skipped` is reserved for a step a human skipped from the run console (Phase
+// 3) — a status that meant both "nobody ran this" and "this failed and we moved
+// on" would make the run console's summary line unreadable.
+async function applyFailure(run, step, error) {
+ await stepsDb.finish(step.id, 'failed', error)
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.status',
+ phase: step.phase,
+ detail: { to: 'failed', action: step.action_id, attempts: step.attempts + 1, onFailure: step.on_failure, error },
+ })
+
+ // A run that lost a step is degraded whatever happens next. Health is not
+ // status (§E): a run can be genuinely running and degraded at once, and the
+ // admin surface needs to say so without claiming the run stopped.
+ if (await runsDb.setHealth(run.id, 'degraded')) {
+ await logDb.write({ runId: run.id, kind: 'run.health', detail: { to: 'degraded', because: step.action_id } })
+ }
+
+ if (step.on_failure === 'abort_run') {
+ // §L: the disposition for `irreversible`. Nothing further is dispatched, and
+ // the pending steps are cancelled rather than left looking due forever.
+ const cancelled = await stepsDb.cancelPending(run.id)
+ await runsDb.transition(run.id, ['starting', 'running', 'ending'], 'failed', { error })
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ phase: step.phase,
+ detail: { to: 'failed', because: step.action_id, cancelledSteps: cancelled },
+ })
+ return 'stop'
+ }
+
+ if (step.on_failure === 'pause') {
+ // The default for `change`, and the right one when the world is half-altered:
+ // stop advancing and wait for a human. `paused` is excluded from `findDue`,
+ // so nothing here picks it up again — Phase 3's resume control is the only
+ // thing that moves it.
+ await runsDb.transition(run.id, ['starting', 'running'], 'paused', { error })
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ phase: step.phase,
+ detail: { to: 'paused', because: step.action_id },
+ })
+ return 'stop'
+ }
+
+ // 'skip': the run carries on, degraded, and the failure is on the record.
+ return 'continue'
+}
+
+/**
+ * Claim one step, dispatch it, and record what came back.
+ *
+ * Answers `'continue'` (the phase may proceed), `'stop'` (it may not, for now or
+ * ever) or `'taken'` (somebody else claimed it first).
+ */
+async function drainStep(run, step, now, carry = {}) {
+ if (!(await stepsDb.claim(step.id, OWNER, leaseFor(step, now), now))) return 'taken'
+
+ const result = await dispatchStep(step, { run })
+
+ if (result.actionVersionDrift) {
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.status',
+ phase: step.phase,
+ detail: { action: step.action_id, versionDrift: result.actionVersionDrift },
+ })
+ }
+
+ if (result.outcome === 'parked') {
+ // The GM cue. The step stays `running` with a NULL lease: genuinely in
+ // flight, nothing holding it, so the stale reclaim passes it by and a cue
+ // posted on Friday is still waiting on Monday. Phase 3's confirm control is
+ // what ends it.
+ await stepsDb.park(step.id, null)
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.parked',
+ phase: step.phase,
+ detail: { action: step.action_id, params: step.params },
+ })
+ return 'stop'
+ }
+
+ if (result.outcome === 'done') {
+ await stepsDb.finish(step.id, 'done', null)
+ if (result.holdSeconds > 0) {
+ // `core.wait`, and any module action that answers `holdFor`. The pause is
+ // the NEXT step's `due_at` and it is set here, by the runner, because a
+ // `perform()` that slept would hold its claim for the duration.
+ const until = new Date(now.getTime() + result.holdSeconds * 1000)
+ if (!(await stepsDb.holdNext(run.id, step.phase, step.seq, until))) {
+ // **Nothing after it in this phase**, which is the case a wait written as
+ // the last step of a phase produces. Dropping the hold here would make
+ // "announce, wait five minutes, then the next phase" start the next phase
+ // at once — a wait that silently meant nothing. The later phase's steps do
+ // not exist yet, so the instant is carried out to `advanceRun` and applied
+ // when they are materialised.
+ carry.holdUntil = until
+ }
+ }
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.status',
+ phase: step.phase,
+ detail: { to: 'done', action: step.action_id, holdSeconds: result.holdSeconds || 0 },
+ })
+ return 'continue'
+ }
+
+ if (result.outcome === 'retry' && step.attempts + 1 < MAX_ATTEMPTS) {
+ await stepsDb.reschedule(step.id, new Date(now.getTime() + RETRY_MS), result.error)
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.retry',
+ phase: step.phase,
+ detail: { action: step.action_id, attempt: step.attempts + 1, of: MAX_ATTEMPTS, error: result.error },
+ })
+ // Degraded from the FIRST retry, not from the eventual failure. An event
+ // whose announcements are landing on the second attempt is having trouble
+ // now, and that is when an operator wants to know.
+ if (await runsDb.setHealth(run.id, 'degraded')) {
+ await logDb.write({ runId: run.id, kind: 'run.health', detail: { to: 'degraded', because: step.action_id } })
+ }
+ return 'stop'
+ }
+
+ // Terminal, or transient with the attempts spent. Same disposition either way:
+ // §L's `retry(n) -> ...` has arrived at the arrow.
+ return applyFailure(run, step, result.error)
+}
+
+/**
+ * Advance one claimed run as far as it will go this tick.
+ *
+ * The loop is bounded by `STEPS_PER_TICK` and exits on the first thing it cannot
+ * get past — a parked step, a step whose `due_at` is in the future, a step
+ * somebody else holds, or a phase that is not finished.
+ */
+async function advanceRun(run, now) {
+ const version = await versionsDb.getById(run.version_id)
+ const phases = version?.spec?.phases
+ if (!Array.isArray(phases) || !phases.length) {
+ // The pinned version is unreadable. `version_id`'s foreign key RESTRICTs
+ // precisely so this cannot be a deleted row, so it is corruption rather than
+ // an ordinary race — terminal, named, and not retried.
+ await runsDb.transition(run.id, ['scheduled', 'starting', 'running', 'ending'], 'failed', {
+ error: 'the pinned version has no phases',
+ })
+ await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: 'pinned version has no phases' } })
+ return 'failed'
+ }
+
+ let phaseKey = run.current_phase
+
+ if (run.status === 'starting') {
+ // Entering the first phase. `materialisePhase` is INSERT IGNORE against
+ // `uq_evstep_slot`, so doing it again over the rows Phase 1's `create()`
+ // already wrote is a no-op — which is what makes recovery from a process that
+ // died between the claim and here uneventful.
+ const first = phases[0]
+ await stepsDb.materialisePhase(run.id, first.key, first.steps || [])
+ if (!(await runsDb.transition(run.id, 'starting', 'running', { phase: first.key }))) return 'taken'
+ phaseKey = first.key
+ await logDb.write({ runId: run.id, kind: 'run.status', phase: first.key, detail: { from: 'starting', to: 'running' } })
+ }
+
+ if (run.status === 'ending') {
+ // A run that reached the wind-down and then lost its process. Phase 8 puts
+ // cleanup here; until then `ending` is a state a run passes through rather
+ // than one it does work in, and completing it is the whole recovery.
+ await runsDb.transition(run.id, 'ending', 'completed')
+ await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } })
+ return 'completed'
+ }
+
+ // A hold a `core.wait` could not place because nothing followed it in its own
+ // phase. It crosses the phase boundary with the run rather than being dropped.
+ const carry = {}
+
+ for (let n = 0; n < STEPS_PER_TICK; n++) {
+ const phaseIndex = phases.findIndex((p) => p.key === phaseKey)
+ if (phaseIndex < 0) {
+ await runsDb.transition(run.id, ['running'], 'failed', { error: `phase "${phaseKey}" is not in the pinned version` })
+ await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: `unknown phase "${phaseKey}"` } })
+ return 'failed'
+ }
+
+ const step = await stepsDb.nextOpenStep(run.id, phaseKey)
+
+ if (step && step.status === 'running') return 'in-flight' // parked, or somebody's dispatch
+ if (step && step.due_at && new Date(step.due_at) > now) return 'waiting' // behind a core.wait
+
+ if (step) {
+ const outcome = await drainStep({ ...run, current_phase: phaseKey }, step, now, carry)
+ if (outcome === 'continue') continue
+ return outcome === 'taken' ? 'taken' : 'stopped'
+ }
+
+ // Every step of this phase is terminal.
+ await logDb.write({ runId: run.id, kind: 'phase.completed', phase: phaseKey, detail: { index: phaseIndex } })
+
+ const next = phases[phaseIndex + 1]
+ if (!next) {
+ // §E's `ending` exists for the reason `sending` does in the outbox — it is
+ // what a claim sets — so the run passes through it even though Phase 2 has
+ // no cleanup to do there. Phase 8 is what gives it work.
+ if (!(await runsDb.transition(run.id, 'running', 'ending'))) return 'taken'
+ await logDb.write({ runId: run.id, kind: 'run.status', phase: phaseKey, detail: { from: 'running', to: 'ending' } })
+ await runsDb.transition(run.id, 'ending', 'completed')
+ await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } })
+ return 'completed'
+ }
+
+ await stepsDb.materialisePhase(run.id, next.key, next.steps || [])
+ if (carry.holdUntil) {
+ // `seq > -1` is the first step of the phase just created. Applied after
+ // materialisation because that is the first moment there is a row to hold.
+ await stepsDb.holdNext(run.id, next.key, -1, carry.holdUntil)
+ carry.holdUntil = null
+ }
+ // `running -> running` is not a no-op: it is a guarded write of
+ // `current_phase` that fails if the run stopped being `running` underneath
+ // this tick, which is what a cancel from the admin surface looks like.
+ if (!(await runsDb.transition(run.id, 'running', 'running', { phase: next.key }))) return 'taken'
+ phaseKey = next.key
+ await logDb.write({ runId: run.id, kind: 'phase.entered', phase: next.key, detail: { steps: (next.steps || []).length } })
+ }
+
+ return 'bounded' // more to do; the next tick picks it up
+}
+
+/** Claim one due run, work it, and hand the lease back if it is still in flight. */
+async function processRun(run, now = new Date()) {
+ if (run.status === 'scheduled') {
+ const holder = await runsDb.concurrencyHolder(run.concurrency_key, run.id)
+ if (holder) {
+ // The org lead's answer for a held key (2026-09-02): hold at `scheduled`
+ // and let the grace window decide. Nothing is destroyed, nothing starts
+ // silently late, and if the holder outlasts the window the missed sweep
+ // makes this run terminal and visible.
+ //
+ // Logged only when the reason CHANGES. A blocked run is re-examined every
+ // tick, and a line per tick for the length of a grace window would bury the
+ // one line that matters under a thousand identical ones.
+ const message = `held: run ${holder.id} has concurrency key "${run.concurrency_key}"`
+ if (run.last_error !== message) {
+ await runsDb.transition(run.id, 'scheduled', 'scheduled', { error: message })
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.blocked',
+ detail: { concurrencyKey: run.concurrency_key, heldBy: holder.id, holderStatus: holder.status },
+ })
+ }
+ return 'blocked'
+ }
+
+ if (!(await runsDb.claimStart(run.id, OWNER, new Date(now.getTime() + RUN_LEASE_MS)))) return 'taken'
+ await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'scheduled', to: 'starting' } })
+ run = { ...run, status: 'starting' }
+ } else if (!(await runsDb.claimTick(run.id, OWNER, new Date(now.getTime() + RUN_LEASE_MS), now))) {
+ return 'taken'
+ }
+
+ try {
+ const outcome = await advanceRun(run, now)
+ // A run that is not finished must not keep its lease: it would be
+ // unadvanceable until the lease expired, which would turn every `core.wait`
+ // into `max(wait, RUN_LEASE_MS)`. A terminal transition already cleared it.
+ if (!['completed', 'failed'].includes(outcome)) await runsDb.releaseClaim(run.id, OWNER)
+ return outcome
+ } catch (err) {
+ await runsDb.releaseClaim(run.id, OWNER)
+ throw err
+ }
+}
+
+/** Occurrences that passed their own grace window while nothing was running (§L). */
+async function sweepMissed(now) {
+ const missed = await runsDb.findMissed(now)
+ let n = 0
+ for (const run of missed) {
+ if (await runsDb.transition(run.id, 'scheduled', 'missed', { error: 'the grace window passed' })) {
+ await stepsDb.cancelPending(run.id)
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ detail: { to: 'missed', scheduledFor: run.scheduled_for },
+ })
+ n += 1
+ }
+ }
+ return n
+}
+
+let lastPruneAt = 0
+
+async function prune(now) {
+ if (now.getTime() - lastPruneAt < PRUNE_EVERY_MS) return 0
+ lastPruneAt = now.getTime()
+ const before = new Date(now.getTime() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000)
+ const deleted = await logDb.pruneTerminal(before)
+ if (deleted) log.info('event run log pruned', { deleted, before, retentionDays: LOG_RETENTION_DAYS })
+ return deleted
+}
+
+async function tick(now = new Date()) {
+ try {
+ await runsDb.reclaimStale(now)
+ await stepsDb.reclaimStale(now, MAX_ATTEMPTS)
+ } catch (err) {
+ log.error('failed to reclaim stale claims', { message: err.message })
+ }
+
+ try {
+ await sweepMissed(now)
+ } catch (err) {
+ log.error('missed sweep failed', { message: err.message })
+ }
+
+ let due
+ try {
+ due = await runsDb.findDue(now, RUN_BATCH)
+ } catch (err) {
+ log.error('failed to load due runs', { message: err.message })
+ return
+ }
+
+ const counts = {}
+ for (const run of due || []) {
+ try {
+ const outcome = await processRun(run, now)
+ counts[outcome] = (counts[outcome] || 0) + 1
+ } catch (err) {
+ log.error('run failed', { run: run.id, message: err.message })
+ }
+ }
+ if (due && due.length) log.info('event runs swept', { due: due.length, ...counts })
+
+ try {
+ await prune(now)
+ } catch (err) {
+ log.error('log prune failed', { message: err.message })
+ }
+}
+
+let timer = null
+// Guards against this process running two ticks over the same runs at once.
+// `setInterval` fires whether or not the last callback returned, and the CAS
+// alone does not cover it now that a live lease is not re-enterable by its own
+// owner — a second tick would simply find every run claimed and do nothing
+// useful, one query at a time, for as long as the first one ran.
+let ticking = false
+
+function start() {
+ if (timer) return timer
+ timer = setInterval(() => {
+ if (ticking) {
+ log.warn('event tick still running; skipping this interval')
+ return
+ }
+ ticking = true
+ tick()
+ .catch((err) => log.error('event tick failed', { message: err.message }))
+ .finally(() => {
+ ticking = false
+ })
+ }, POLL_MS)
+ if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
+ log.info('event runner started', { pollMs: POLL_MS, owner: OWNER, maxAttempts: MAX_ATTEMPTS })
+ return timer
+}
+
+function stop() {
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+}
+
+module.exports = {
+ start,
+ stop,
+ tick,
+ processRun,
+ advanceRun,
+ drainStep,
+ sweepMissed,
+ prune,
+ OWNER,
+ POLL_MS,
+ MAX_ATTEMPTS,
+ RETRY_MS,
+ RUN_LEASE_MS,
+ LOG_RETENTION_DAYS,
+ RUN_TERMINAL,
+}
diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js
index c3882e1..d8fc957 100644
--- a/server/test/eventActionRegistry.test.js
+++ b/server/test/eventActionRegistry.test.js
@@ -63,17 +63,70 @@ test('the catalog carries no callable', () => {
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
})
-test('core placeholders refuse rather than claiming success', async () => {
- // Phase 1 declares; Phase 2 dispatches. The placeholder's answer matters
- // because `ok: true` on an action that did nothing is a recorded world change
- // that did not occur — the one wrong answer a stub can give.
+test('core.announce refuses an unregistered leg terminally, and never claims success', async () => {
+ // Phase 1's version of this test asserted that all three core actions REFUSED,
+ // because none of them was wired yet. Phase 2 gave them real bodies, so what
+ // survives is the half that was never about the placeholder: `ok: true` on an
+ // action that did nothing is a recorded world change that did not occur.
+ //
+ // `core.announce` is the one that can still legitimately refuse. A leg nobody
+ // registers will not appear between two attempts a minute apart, so the answer
+ // is terminal rather than transient — a human has to fix it.
registries.registerCore()
- for (const id of ['core.announce', 'core.wait', 'core.cue']) {
- const answer = await registries.eventAction(id).perform({})
- assert.equal(answer.ok, false)
- assert.equal(answer.retry, false)
- assert.match(answer.error, new RegExp(id.replace('.', '\\.')))
+ const answer = await registries.eventAction('core.announce').perform({
+ params: { leg: 'nowhere', body: 'hello' },
+ })
+ assert.equal(answer.ok, false)
+ assert.equal(answer.retry, false)
+ assert.match(answer.error, /nowhere/)
+})
+
+test('a dry run validates and reports, but dispatches nothing', async () => {
+ // §I's dry run: `verify === true` means validate and report, change nothing.
+ // `core.announce` is the only core action with an outside effect to suppress.
+ //
+ // **The leg is resolved BEFORE `verify` is honoured, and that ordering is the
+ // point rather than an oversight.** A dry run exists to report what would
+ // happen, and "this step names a leg nobody registers" is the most useful thing
+ // it can find. Answering `ok: true` first would make the dry run pass on
+ // exactly the definition that cannot work.
+ registries.registerCore()
+ const announce = registries.eventAction('core.announce')
+
+ const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true })
+ assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist')
+
+ // A registered leg: reported good, and its transport never touched.
+ const leg = registries.announceLeg('discord')
+ const dispatch = leg.dispatch
+ let dispatched = 0
+ leg.dispatch = async () => {
+ dispatched += 1
+ return { ok: true }
}
+ try {
+ const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true })
+ assert.equal(good.ok, true)
+ assert.equal(dispatched, 0, 'a dry run sends nothing')
+ } finally {
+ leg.dispatch = dispatch
+ }
+})
+
+test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => {
+ // Both answer through ordinary envelope members, which is what lets the runner
+ // honour them without knowing what either action is. A `perform` that slept
+ // would hold its claim for the duration and turn a five-minute pause into a
+ // five-minute lease.
+ registries.registerCore()
+ assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), {
+ ok: true,
+ holdFor: 300,
+ })
+ assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), {
+ ok: true,
+ await: 'human',
+ })
})
test('an action must be namespaced to its owner, and the holder is named', () => {
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
new file mode 100644
index 0000000..33b1abf
--- /dev/null
+++ b/server/test/eventRunner.test.js
@@ -0,0 +1,689 @@
+// ── The event runner (EVENTS_PLAN.md Phase 2) ──────────────────────────────
+//
+// The phase's shipped claim, first: **a manually started event that broadcasts,
+// waits, and completes.** Then the properties around it that are not behaviour
+// so much as promises — the ones §E and §L make, and the two this codebase has
+// already paid for once:
+//
+// • a reclaim never resets `attempts` (Engagement Phase 14's defect)
+// • a parked GM cue is not stale, however long it waits
+// • no shape a failure can take reads as success (§F)
+// • a step naming an unregistered action fails terminal with the module named
+// and degrades the run — never a silent skip (§L)
+// • the three `on_failure` dispositions do three different things to the RUN
+// • the idempotency key does not vary by attempt (§E)
+//
+// **The three tables are stubbed at the `.db` layer** and the runner's own logic
+// runs for real against them — the shape `engagementEngine.test.js` uses. What a
+// stub cannot prove is the raw SQL whose correctness IS a server contract: the
+// two CAS claims, the lease reclaim's two-statement order, and `holdNext`'s
+// guard. Those run against a real MariaDB in `eventRunnerSql.test.js`, which
+// skips when there is none. A stub reproduces the reading, not the server.
+//
+// Point the DB at a closed port before requiring anything: the registries reach
+// utils/discordAnnounce, which builds the pool at require time.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const runner = require('../src/utils/eventRunner')
+const { classify } = require('../src/events/dispatch')
+const runsDb = require('../src/model/events/eventRuns.db')
+const stepsDb = require('../src/model/events/eventRunSteps.db')
+const logDb = require('../src/model/events/eventRunLog.db')
+const versionsDb = require('../src/model/events/eventVersions.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const T0 = new Date('2026-09-02T12:00:00Z')
+const later = (ms) => new Date(T0.getTime() + ms)
+
+// ── In-memory stand-ins for the three tables ───────────────────────────────
+
+let store
+const originals = {}
+
+for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) {
+ originals[name] = { mod, fns: { ...mod } }
+}
+
+const restoreOriginals = () => {
+ for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
+}
+
+const TERMINAL_RUN = ['completed', 'cancelled', 'failed', 'missed']
+const clone = (o) => JSON.parse(JSON.stringify(o, (k, v) => v))
+
+function installStubs() {
+ store = {
+ runs: new Map(),
+ steps: new Map(),
+ log: [],
+ versions: new Map(),
+ definitions: new Map(),
+ nextStepId: 1,
+ }
+
+ // Snapshots, not live references. A SQL SELECT hands back a copy, and the
+ // runner reads `step.attempts` as the value BEFORE its own claim incremented
+ // it — returning references here would make the retry budget off by one in the
+ // stub only, which is exactly the class of thing a stub must not invent.
+ const snapRun = (r) => ({ ...r })
+ const snapStep = (s) => ({ ...s, params: { ...(s.params || {}) } })
+
+ runsDb.findDue = async (now) =>
+ [...store.runs.values()]
+ .filter((r) => ['scheduled', 'starting', 'running', 'ending'].includes(r.status) && r.scheduled_for <= now)
+ .sort((a, b) => a.scheduled_for - b.scheduled_for || a.id - b.id)
+ .map(snapRun)
+
+ runsDb.findMissed = async (now) =>
+ [...store.runs.values()]
+ .filter((r) => {
+ const grace = store.definitions.get(r.definition_id)?.grace_seconds ?? 900
+ return r.status === 'scheduled' && r.scheduled_for.getTime() + grace * 1000 < now.getTime()
+ })
+ .map(snapRun)
+
+ runsDb.claimStart = async (id, owner, lease) => {
+ const r = store.runs.get(id)
+ if (!r || r.status !== 'scheduled') return false
+ Object.assign(r, { status: 'starting', claimed_by: owner, claim_expires_at: lease, started_at: r.started_at || T0 })
+ return true
+ }
+
+ runsDb.claimTick = async (id, owner, lease, now) => {
+ const r = store.runs.get(id)
+ if (!r || !['starting', 'running', 'ending'].includes(r.status)) return false
+ // No owner-matches escape: a live lease is not re-enterable, not even by the
+ // process that took it. The stub agrees with the statement on purpose.
+ if (r.claim_expires_at && r.claim_expires_at >= now) return false
+ Object.assign(r, { claimed_by: owner, claim_expires_at: lease })
+ return true
+ }
+
+ runsDb.releaseClaim = async (id, owner) => {
+ const r = store.runs.get(id)
+ if (!r || r.claimed_by !== owner) return false
+ Object.assign(r, { claimed_by: null, claim_expires_at: null })
+ return true
+ }
+
+ runsDb.transition = async (id, from, to, opts = {}) => {
+ const r = store.runs.get(id)
+ const froms = Array.isArray(from) ? from : [from]
+ if (!r || !froms.includes(r.status)) return false
+ r.status = to
+ if (opts.phase !== undefined) r.current_phase = opts.phase
+ if (opts.error !== undefined) r.last_error = opts.error
+ if (TERMINAL_RUN.includes(to)) {
+ r.ended_at = r.ended_at || T0
+ r.claimed_by = null
+ r.claim_expires_at = null
+ } else if (opts.clearClaim) {
+ r.claimed_by = null
+ r.claim_expires_at = null
+ }
+ return true
+ }
+
+ runsDb.setHealth = async (id, health) => {
+ const r = store.runs.get(id)
+ if (!r || r.health === health) return false
+ r.health = health
+ return true
+ }
+
+ runsDb.concurrencyHolder = async (key, exceptId) => {
+ if (!key) return null
+ const held = [...store.runs.values()].find(
+ (r) => r.concurrency_key === key && r.id !== exceptId && ['starting', 'running', 'paused', 'ending'].includes(r.status),
+ )
+ return held ? { id: held.id, status: held.status, definition_id: held.definition_id } : null
+ }
+
+ runsDb.reclaimStale = async (now) => {
+ let n = 0
+ for (const r of store.runs.values()) {
+ if (['starting', 'running', 'ending'].includes(r.status) && r.claim_expires_at && r.claim_expires_at < now) {
+ r.claimed_by = null
+ r.claim_expires_at = null
+ n += 1
+ }
+ }
+ return n
+ }
+
+ stepsDb.materialisePhase = async (runId, phase, steps) => {
+ steps.forEach((s, i) => {
+ // INSERT IGNORE against uq_evstep_slot (run_id, phase, seq).
+ const exists = [...store.steps.values()].find((x) => x.run_id === runId && x.phase === phase && x.seq === i)
+ if (exists) return
+ const id = store.nextStepId++
+ store.steps.set(id, {
+ id,
+ run_id: runId,
+ phase,
+ seq: i,
+ action_id: s.actionId,
+ params: s.params || {},
+ action_version: s.actionVersion || 1,
+ status: 'pending',
+ due_at: null,
+ attempts: 0,
+ on_failure: s.onFailure || 'pause',
+ idempotency_key: stepsDb.idempotencyKey(runId, id),
+ claimed_by: null,
+ claim_expires_at: null,
+ last_error: null,
+ })
+ })
+ return [...store.steps.values()].filter((s) => s.run_id === runId).map(snapStep)
+ }
+
+ stepsDb.nextOpenStep = async (runId, phase) => {
+ const s = [...store.steps.values()]
+ .filter((x) => x.run_id === runId && x.phase === phase && ['pending', 'running'].includes(x.status))
+ .sort((a, b) => a.seq - b.seq || a.id - b.id)[0]
+ return s ? snapStep(s) : null
+ }
+
+ stepsDb.claim = async (id, owner, lease, now) => {
+ const s = store.steps.get(id)
+ if (!s || s.status !== 'pending') return false
+ if (s.due_at && s.due_at > now) return false
+ Object.assign(s, { status: 'running', attempts: s.attempts + 1, claimed_by: owner, claim_expires_at: lease })
+ return true
+ }
+
+ stepsDb.park = async (id) => {
+ const s = store.steps.get(id)
+ if (s && s.status === 'running') s.claim_expires_at = null
+ }
+
+ stepsDb.reschedule = async (id, dueAt, error) => {
+ const s = store.steps.get(id)
+ if (!s || s.status !== 'running') return
+ // `attempts` is untouched: the claim already incremented it, and nothing else
+ // may. This is the stub agreeing with the statement, not with the runner.
+ Object.assign(s, { status: 'pending', due_at: dueAt, claimed_by: null, claim_expires_at: null, last_error: error })
+ }
+
+ stepsDb.finish = async (id, status, error) => {
+ const s = store.steps.get(id)
+ if (!s || s.status !== 'running') return
+ Object.assign(s, { status, last_error: error, claimed_by: null, claim_expires_at: null, finished_at: T0 })
+ }
+
+ stepsDb.holdNext = async (runId, phase, afterSeq, dueAt) => {
+ const s = [...store.steps.values()]
+ .filter((x) => x.run_id === runId && x.phase === phase && x.seq > afterSeq && x.status === 'pending')
+ .filter((x) => !x.due_at || x.due_at < dueAt)
+ .sort((a, b) => a.seq - b.seq)[0]
+ if (!s) return false
+ s.due_at = dueAt
+ return true
+ }
+
+ stepsDb.reclaimStale = async (now, maxAttempts = 0) => {
+ let failed = 0
+ let reclaimed = 0
+ // Give up first, reclaim second — the order the statement uses, and the
+ // reason `MAX_ATTEMPTS` is reachable at all.
+ for (const s of store.steps.values()) {
+ if (s.status === 'running' && s.claim_expires_at && s.claim_expires_at < now && maxAttempts > 0 && s.attempts >= maxAttempts) {
+ Object.assign(s, { status: 'failed', last_error: 'gave up after repeated interruptions', claimed_by: null, claim_expires_at: null })
+ failed += 1
+ }
+ }
+ for (const s of store.steps.values()) {
+ if (s.status === 'running' && s.claim_expires_at && s.claim_expires_at < now) {
+ // NOT reset: attempts survives the reclaim.
+ Object.assign(s, { status: 'pending', claimed_by: null, claim_expires_at: null })
+ reclaimed += 1
+ }
+ }
+ return { failed, reclaimed }
+ }
+
+ stepsDb.cancelPending = async (runId) => {
+ let n = 0
+ for (const s of store.steps.values()) {
+ if (s.run_id === runId && s.status === 'pending') {
+ s.status = 'cancelled'
+ n += 1
+ }
+ }
+ return n
+ }
+
+ stepsDb.listForRun = async (runId) =>
+ [...store.steps.values()].filter((s) => s.run_id === runId).sort((a, b) => a.seq - b.seq).map(snapStep)
+
+ logDb.write = async (line) => {
+ store.log.push(line)
+ return true
+ }
+ logDb.pruneTerminal = async () => 0
+
+ versionsDb.getById = async (id) => store.versions.get(id) || null
+}
+
+// ── Fixtures ───────────────────────────────────────────────────────────────
+
+let nextRunId = 1
+
+function seedRun(phases, { scheduledFor = T0, graceSeconds = 900, concurrencyKey = null, status = 'scheduled' } = {}) {
+ const id = nextRunId++
+ store.definitions.set(id, { id, grace_seconds: graceSeconds })
+ store.versions.set(id, { id, spec: { schedule: { kind: 'manual' }, phases } })
+ store.runs.set(id, {
+ id,
+ definition_id: id,
+ version_id: id,
+ scope: '',
+ status,
+ health: 'ok',
+ cleanup_status: 'not_required',
+ current_phase: null,
+ scheduled_for: scheduledFor,
+ concurrency_key: concurrencyKey,
+ params: null,
+ rehearsal: 0,
+ claimed_by: null,
+ claim_expires_at: null,
+ last_error: null,
+ started_at: null,
+ ended_at: null,
+ })
+ // Phase 1's `create()` materialises the FIRST phase at creation rather than at
+ // start, so a seeded run has to as well — otherwise every test here would be
+ // exercising a shape the admin route cannot produce.
+ const first = phases[0]
+ if (first) void stepsDb.materialisePhase(id, first.key, first.steps || [])
+ return id
+}
+
+const step = (actionId, params = {}, onFailure = 'skip') => ({ actionId, params, onFailure, actionVersion: 1 })
+const run = (id) => store.runs.get(id)
+const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
+const kinds = (id) => store.log.filter((l) => l.runId === id).map((l) => l.kind)
+
+// A registered test action whose behaviour the test dictates.
+let scripted
+
+beforeEach(() => {
+ registries._reset()
+ installStubs()
+ nextRunId = 1
+ scripted = {}
+})
+
+afterEach(() => {
+ restoreOriginals()
+ registries._reset()
+})
+
+/** Register actions the way a module does, through the real staging area. */
+const register = (entries, owner = 'test') => {
+ const api = registries.stage(owner)
+ api.registerEventActions(entries)
+ registries.apply(api.staged)
+}
+
+// ── Registering actions the tests drive ────────────────────────────────────
+//
+// Registered through the real registry rather than by stubbing `eventAction`,
+// because the shape check at registration is part of what the runner relies on:
+// an action that would not register is not one the runner has to survive.
+
+const scriptedAction = (id, extra = {}) => ({
+ id,
+ label: id,
+ risk: 'notify',
+ reversible: 'none',
+ version: 1,
+ budgetMs: 1000,
+ params: [],
+ perform: async (envelope) => {
+ ;(scripted[id] ||= { calls: [] }).calls.push(envelope)
+ const answer = scripted[id].answers?.shift() ?? scripted[id].answer
+ if (typeof answer === 'function') return answer(envelope)
+ return answer ?? { ok: true }
+ },
+ ...extra,
+})
+
+test('a manually started event announces, waits and completes', async () => {
+ register([scriptedAction('test.announce'), scriptedAction('test.wait')])
+ scripted['test.wait'] = { calls: [], answer: { ok: true, holdFor: 300 } }
+
+ const id = seedRun([
+ { key: 'main', label: 'Main', steps: [step('test.announce'), step('test.wait'), step('test.announce')] },
+ ])
+
+ // Tick one: announce, then wait, then stop against the held third step.
+ await runner.tick(T0)
+ let s = stepsOf(id)
+ assert.equal(s[0].status, 'done')
+ assert.equal(s[1].status, 'done')
+ assert.equal(s[2].status, 'pending', 'the step after a wait must not run in the same tick')
+ assert.equal(s[2].due_at.getTime(), later(300_000).getTime(), 'the wait is the NEXT step due_at')
+ assert.equal(run(id).status, 'running')
+ assert.equal(run(id).claimed_by, null, 'a run left in flight gives its lease back')
+
+ // Tick two, still inside the wait: nothing moves.
+ await runner.tick(later(120_000))
+ assert.equal(stepsOf(id)[2].status, 'pending')
+ assert.equal(run(id).status, 'running')
+
+ // Tick three, past it: the last step runs and the run completes.
+ await runner.tick(later(301_000))
+ assert.equal(stepsOf(id)[2].status, 'done')
+ assert.equal(run(id).status, 'completed')
+ assert.equal(run(id).health, 'ok')
+ assert.ok(kinds(id).includes('phase.completed'))
+ assert.ok(kinds(id).includes('run.status'))
+})
+
+test('a run passes through `ending` on its way to completed', async () => {
+ register([scriptedAction('test.noop')])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }])
+ await runner.tick(T0)
+
+ const transitions = store.log.filter((l) => l.runId === id && l.kind === 'run.status').map((l) => l.detail.to)
+ assert.deepEqual(transitions, ['starting', 'running', 'ending', 'completed'])
+})
+
+test('phases run in order and the next one is materialised on entry', async () => {
+ register([scriptedAction('test.noop')])
+
+ const id = seedRun([
+ { key: 'opening', label: 'Opening', steps: [step('test.noop')] },
+ { key: 'closing', label: 'Closing', steps: [step('test.noop'), step('test.noop')] },
+ ])
+
+ await runner.tick(T0)
+ assert.equal(run(id).status, 'completed')
+ assert.deepEqual(stepsOf(id).map((s) => s.phase), ['opening', 'closing', 'closing'])
+ assert.ok(stepsOf(id).every((s) => s.status === 'done'))
+})
+
+test('a wait as the last step of a phase holds the NEXT phase, rather than meaning nothing', async () => {
+ register([scriptedAction('test.noop'), scriptedAction('test.wait')])
+ scripted['test.wait'] = { calls: [], answer: { ok: true, holdFor: 300 } }
+
+ const id = seedRun([
+ { key: 'opening', label: 'Opening', steps: [step('test.noop'), step('test.wait')] },
+ { key: 'closing', label: 'Closing', steps: [step('test.noop')] },
+ ])
+
+ await runner.tick(T0)
+ const closing = stepsOf(id).filter((x) => x.phase === 'closing')
+ assert.equal(closing.length, 1, 'the next phase is materialised')
+ assert.equal(closing[0].status, 'pending')
+ assert.equal(
+ closing[0].due_at.getTime(),
+ later(300_000).getTime(),
+ 'the hold crosses the phase boundary; dropping it would start the next phase at once',
+ )
+ assert.equal(run(id).status, 'running')
+
+ await runner.tick(later(301_000))
+ assert.equal(run(id).status, 'completed')
+})
+
+test('a GM cue parks: the step stays running with no lease, and the reclaim leaves it alone', async () => {
+ register([scriptedAction('test.cue'), scriptedAction('test.after')])
+ scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue'), step('test.after')] }])
+
+ await runner.tick(T0)
+ const cue = stepsOf(id)[0]
+ assert.equal(cue.status, 'running')
+ assert.equal(cue.claim_expires_at, null, 'a parked step carries no lease')
+ assert.equal(stepsOf(id)[1].status, 'pending', 'nothing after a cue proceeds')
+ assert.ok(kinds(id).includes('step.parked'))
+
+ // A week later the reclaim has still not touched it, and the cue has been
+ // dispatched exactly once. This is the whole point of a NULL lease.
+ await runner.tick(later(7 * 24 * 60 * 60 * 1000))
+ assert.equal(stepsOf(id)[0].status, 'running')
+ assert.equal(stepsOf(id)[0].attempts, 1)
+ assert.equal(scripted['test.cue'].calls.length, 1)
+ assert.equal(run(id).status, 'running')
+})
+
+test('a reclaim returns a stale step without resetting attempts', async () => {
+ register([scriptedAction('test.slow')])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.slow')] }])
+
+ // Simulate a process that claimed the step and died: running, lease in the past.
+ await runner.tick(T0)
+ const s = stepsOf(id)[0]
+ Object.assign(store.steps.get(s.id), { status: 'running', attempts: 2, claim_expires_at: later(-1000) })
+
+ await stepsDb.reclaimStale(T0, runner.MAX_ATTEMPTS)
+ assert.equal(store.steps.get(s.id).status, 'pending')
+ assert.equal(store.steps.get(s.id).attempts, 2, 'Engagement Phase 14: a reclaim must never reset attempts')
+})
+
+test('a step whose attempts are spent leaves `running` as failed rather than being handed back', async () => {
+ register([scriptedAction('test.slow')])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.slow')] }])
+ await runner.tick(T0)
+ const s = stepsOf(id)[0]
+ Object.assign(store.steps.get(s.id), { status: 'running', attempts: runner.MAX_ATTEMPTS, claim_expires_at: later(-1000) })
+
+ const { failed, reclaimed } = await stepsDb.reclaimStale(T0, runner.MAX_ATTEMPTS)
+ assert.equal(failed, 1)
+ assert.equal(reclaimed, 0, 'a row that gave up must not also be reclaimed, or it retries forever')
+ assert.equal(store.steps.get(s.id).status, 'failed')
+})
+
+test('a transient failure retries on a flat backoff with the same idempotency key, then applies on_failure', async () => {
+ register([scriptedAction('test.flaky')])
+ scripted['test.flaky'] = { calls: [], answer: { ok: false, retry: true, error: 'relay is down' } }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.flaky', {}, 'skip')] }])
+ const key = () => stepsOf(id)[0].idempotency_key
+
+ await runner.tick(T0)
+ const firstKey = key()
+ assert.equal(stepsOf(id)[0].status, 'pending')
+ assert.equal(stepsOf(id)[0].attempts, 1)
+ assert.equal(stepsOf(id)[0].due_at.getTime(), later(runner.RETRY_MS).getTime())
+ assert.equal(run(id).health, 'degraded', 'degraded from the first retry, not from the eventual failure')
+
+ await runner.tick(later(runner.RETRY_MS))
+ assert.equal(stepsOf(id)[0].attempts, 2)
+
+ await runner.tick(later(2 * runner.RETRY_MS))
+ assert.equal(stepsOf(id)[0].attempts, runner.MAX_ATTEMPTS)
+ assert.equal(stepsOf(id)[0].status, 'failed', 'all three dispositions write the step failed')
+ assert.equal(run(id).status, 'completed', 'on_failure: skip lets the run finish')
+ assert.equal(run(id).health, 'degraded')
+
+ assert.equal(key(), firstKey, 'the idempotency key does not vary by attempt')
+ assert.equal(new Set(scripted['test.flaky'].calls.map((c) => c.idempotencyKey)).size, 1)
+})
+
+test('on_failure: pause stops the run and the tick never picks it up again', async () => {
+ register([scriptedAction('test.bad'), scriptedAction('test.after')])
+ scripted['test.bad'] = { calls: [], answer: { ok: false, retry: false, error: 'the world is half changed' } }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.bad', {}, 'pause'), step('test.after')] }])
+
+ await runner.tick(T0)
+ assert.equal(run(id).status, 'paused')
+ assert.equal(stepsOf(id)[0].status, 'failed')
+ assert.equal(stepsOf(id)[1].status, 'pending', 'a paused run leaves its remaining steps alone')
+
+ await runner.tick(later(60_000))
+ assert.equal(run(id).status, 'paused', 'only Phase 3 resume moves a paused run')
+ assert.equal(scripted['test.after']?.calls?.length ?? 0, 0)
+})
+
+test('on_failure: abort_run fails the run and cancels what has not started', async () => {
+ register([scriptedAction('test.bad'), scriptedAction('test.after')])
+ scripted['test.bad'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.bad', {}, 'abort_run'), step('test.after')] }])
+
+ await runner.tick(T0)
+ assert.equal(run(id).status, 'failed')
+ assert.equal(stepsOf(id)[0].status, 'failed')
+ assert.equal(stepsOf(id)[1].status, 'cancelled')
+})
+
+test('a step naming an unregistered action fails terminal with the module named, and degrades the run', async () => {
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('gone.verb', {}, 'skip')] }])
+
+ await runner.tick(T0)
+ const s = stepsOf(id)[0]
+ assert.equal(s.status, 'failed', 'never a silent skip (§L)')
+ assert.equal(s.attempts, 1, 'a dormant action is terminal, so it is not retried')
+ assert.match(s.last_error, /gone\.verb/)
+ assert.equal(run(id).health, 'degraded')
+})
+
+test('a held concurrency key holds the run at scheduled, and logs the reason once', async () => {
+ register([scriptedAction('test.noop'), scriptedAction('test.cue')])
+ scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } }
+
+ // The holder is parked on a cue, which is what keeps it genuinely in flight. A
+ // holder with no steps would complete itself on this same tick — correct
+ // behaviour, and a fixture that proved nothing.
+ const holder = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue')] }], {
+ concurrencyKey: 'invasion:Yew',
+ })
+ const waiting = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { concurrencyKey: 'invasion:Yew' })
+
+ await runner.tick(T0)
+ assert.equal(run(waiting).status, 'scheduled')
+ assert.match(run(waiting).last_error, new RegExp(`run ${holder}`))
+ assert.equal(kinds(waiting).filter((k) => k === 'run.blocked').length, 1)
+
+ // Still held, and still one line: a line per tick would bury the one that matters.
+ await runner.tick(later(15_000))
+ assert.equal(kinds(waiting).filter((k) => k === 'run.blocked').length, 1)
+
+ // The holder finishes, and the next tick starts the run that was waiting.
+ store.runs.get(holder).status = 'completed'
+ store.runs.get(holder).claim_expires_at = null
+ await runner.tick(later(30_000))
+ assert.equal(run(waiting).status, 'completed')
+})
+
+test('an occurrence past its own grace window is missed, never a late silent start', async () => {
+ register([scriptedAction('test.noop')])
+
+ const late = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { graceSeconds: 600 })
+ const inside = seedRun([{ key: 'main', label: 'Main', steps: [step('test.noop')] }], { graceSeconds: 3600 })
+
+ // Both are due; the process has been down for half an hour.
+ await runner.tick(later(30 * 60 * 1000))
+
+ assert.equal(run(late).status, 'missed')
+ assert.equal(stepsOf(late)[0].status, 'cancelled')
+ assert.equal(run(inside).status, 'completed', 'inside its window it starts late and says so')
+})
+
+test('a run in `ending` when the process died is completed by the next tick', async () => {
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [] }], { status: 'ending' })
+ store.runs.get(id).current_phase = 'main'
+
+ await runner.tick(T0)
+ assert.equal(run(id).status, 'completed')
+})
+
+test('a live lease is not re-enterable, not even by the process that took it', async () => {
+ register([scriptedAction('test.cue')])
+ scripted['test.cue'] = { calls: [], answer: { ok: true, await: 'human' } }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.cue')] }])
+ await runner.tick(T0)
+
+ // Put a live lease back on the run, as an overrunning tick would have.
+ Object.assign(store.runs.get(id), { claimed_by: runner.OWNER, claim_expires_at: later(60_000) })
+ const taken = await runsDb.claimTick(id, runner.OWNER, later(120_000), T0)
+ assert.equal(taken, false, 'the CAS is what protects a tick that overran into the next one')
+})
+
+// ── §F: no shape a failure can take reads as success ───────────────────────
+
+test('classify: every failure shape is a failure', () => {
+ assert.equal(classify(undefined, 'a').outcome, 'retry')
+ assert.equal(classify(null, 'a').outcome, 'retry')
+ assert.equal(classify('ok', 'a').outcome, 'retry')
+ assert.equal(classify(['ok'], 'a').outcome, 'retry')
+ assert.equal(classify({}, 'a').outcome, 'retry', 'a missing ok is not a success')
+ assert.equal(classify({ ok: 'yes' }, 'a').outcome, 'retry', 'ok must be true, not truthy')
+ assert.equal(classify({ ok: false }, 'a').outcome, 'retry')
+ assert.equal(classify({ ok: false, retry: false }, 'a').outcome, 'terminal')
+ assert.equal(classify({ __timedOut: true, error: 'slow' }, 'a').outcome, 'retry')
+})
+
+test('classify: the two success shapes that are not "finished"', () => {
+ assert.equal(classify({ ok: true }, 'a').outcome, 'done')
+ assert.equal(classify({ ok: true }, 'a').holdSeconds, 0)
+ assert.equal(classify({ ok: true, await: 'human' }, 'a').outcome, 'parked')
+ assert.equal(classify({ ok: true, holdFor: 90 }, 'a').holdSeconds, 90)
+ assert.equal(classify({ ok: true, holdFor: '90' }, 'a').holdSeconds, 90)
+ assert.equal(classify({ ok: true, holdFor: -1 }, 'a').outcome, 'terminal', 'a bad holdFor is not a silent zero')
+ assert.equal(classify({ ok: true, holdFor: 'soon' }, 'a').outcome, 'terminal')
+ assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded')
+})
+
+test('an action that throws is a transient failure, not a crashed tick', async () => {
+ register([
+ scriptedAction('test.thrower', {
+ perform: async () => {
+ throw new Error('boom')
+ },
+ }),
+ ])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.thrower', {}, 'skip')] }])
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'pending')
+ assert.match(stepsOf(id)[0].last_error, /boom/)
+ assert.equal(run(id).status, 'running', 'one bad action does not stop the deployment')
+})
+
+test('an action that never answers is cut off at its declared budget', async () => {
+ register([
+ scriptedAction('test.hang', { budgetMs: 30, perform: () => new Promise(() => {}) }),
+ ])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.hang', {}, 'skip')] }])
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'pending', 'a timeout is transient')
+ assert.match(stepsOf(id)[0].last_error, /budget/)
+})
+
+test('the dispatch envelope carries what §F says it carries', async () => {
+ register([scriptedAction('test.echo')])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.echo', {})] }])
+ store.runs.get(id).scope = 'atlantic'
+ await runner.tick(T0)
+
+ const [envelope] = scripted['test.echo'].calls
+ assert.equal(envelope.runId, id)
+ assert.equal(envelope.scope, 'atlantic')
+ assert.equal(envelope.verify, false)
+ assert.equal(typeof envelope.idempotencyKey, 'string')
+ assert.equal(envelope.idempotencyKey.length, 40)
+ assert.deepEqual(Object.keys(envelope).sort(), ['actor', 'idempotencyKey', 'params', 'runId', 'scope', 'stepId', 'verify'])
+})
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
new file mode 100644
index 0000000..5db23a5
--- /dev/null
+++ b/server/test/eventRunnerSql.test.js
@@ -0,0 +1,508 @@
+// ── The runner's raw SQL, against a real MariaDB ───────────────────────────
+//
+// EVENTS_PLAN.md Phase 2. `eventRunner.test.js` stubs the three tables and
+// exercises everything the runner DECIDES. It cannot prove the statements whose
+// whole correctness is a server contract, and on this codebase that gap has
+// already cost something once: engagement's cooldown claim was green against its
+// stub and always allowed the send against a real server, because the connector
+// defaults `foundRows: true` and a no-op UPDATE reports 1 rather than 0.
+//
+// So the five statements that decide who owns what run here, for real:
+//
+// • **`claimStart`** — the CAS `scheduled -> starting`. "Exactly one winner" is
+// `affectedRows = 1` for one caller and 0 for every other, and that is a
+// property of the SERVER's answer, not of the SQL's shape.
+// • **`claimTick`** — the same, for a run already in flight, and with no
+// owner-matches escape clause. A live lease must refuse its own holder, or
+// one `setInterval` that overran advances one run twice.
+// • **`transition`** — a guarded status move. The guard is the whole thing: a
+// run cancelled between the read and the write must not be transitioned.
+// • **`reclaimStale`** on steps — two statements in a fixed ORDER, give-up
+// before hand-back. Reversing them makes `MAX_ATTEMPTS` unreachable and the
+// row cycles forever (Engagement Phase 14's defect), and **neither statement
+// may touch `attempts`**.
+// • **`holdNext`** — `UPDATE ... ORDER BY seq LIMIT 1` with a guard, which is
+// both a MariaDB-specific syntax and a correctness claim: it must move the
+// next PENDING step and only ever push a due date later.
+//
+// Plus the two unique indexes that are load-bearing rather than tidy:
+// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
+// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
+// a phase a no-op).
+//
+// **It SKIPS when there is no database**, deliberately: CI runs the suite with
+// the pool pointed at a dead port, and a file that failed there would make every
+// PR red for a reason unrelated to itself. Run it against this machine's
+// container with:
+//
+// DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=... DB_PASSWORD=... \
+// node --test test/eventRunnerSql.test.js
+//
+// It creates its tables in a throwaway database named after the process and
+// drops it again, so it can never touch a real schema.
+
+const { test, before, after, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+const mariadb = require('mariadb')
+
+// Trimmed to the columns these statements read or write. The ENUMs are verbatim,
+// because "is `missed` a legal value" is one of the things being proved.
+const SCHEMA = `
+CREATE TABLE event_definitions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ grace_seconds INT NOT NULL DEFAULT 900
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE event_runs (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ definition_id INT NOT NULL,
+ version_id INT NOT NULL,
+ scope VARCHAR(190) NOT NULL DEFAULT '',
+ status ENUM('scheduled','starting','running','paused','ending',
+ 'completed','cancelled','failed','missed')
+ NOT NULL DEFAULT 'scheduled',
+ health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
+ current_phase VARCHAR(64) NULL,
+ scheduled_for DATETIME NOT NULL,
+ concurrency_key VARCHAR(190) NULL,
+ started_at DATETIME NULL,
+ ended_at DATETIME NULL,
+ claimed_by VARCHAR(64) NULL,
+ claim_expires_at DATETIME NULL,
+ last_error VARCHAR(500) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
+ INDEX idx_evrun_due (status, scheduled_for),
+ INDEX idx_evrun_concurrency (concurrency_key, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE event_run_steps (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ run_id BIGINT NOT NULL,
+ phase VARCHAR(64) NOT NULL,
+ seq INT NOT NULL,
+ action_id VARCHAR(96) NOT NULL,
+ params JSON NULL,
+ action_version INT NOT NULL DEFAULT 1,
+ status ENUM('pending','running','done','failed','skipped','refused','cancelled')
+ NOT NULL DEFAULT 'pending',
+ due_at DATETIME NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
+ idempotency_key CHAR(40) NOT NULL,
+ claimed_by VARCHAR(64) NULL,
+ claim_expires_at DATETIME NULL,
+ last_error VARCHAR(500) NULL,
+ started_at DATETIME NULL,
+ finished_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_evstep_slot (run_id, phase, seq),
+ INDEX idx_evstep_due (status, due_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+`
+
+// The statements under test, verbatim from `eventRuns.db.js` and
+// `eventRunSteps.db.js`. Duplicated rather than required, because requiring the
+// modules would drag in `utils/db`'s pool, which the harness has already pointed
+// at a dead port. The pool below leaves `foundRows` at the connector's default,
+// exactly as `utils/db.js` does — pinning it here would make this file agree with
+// the code by construction and prove nothing about the pool the server runs.
+const CLAIM_START = `
+UPDATE event_runs
+ SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
+ started_at = COALESCE(started_at, NOW())
+ WHERE id = ? AND status = 'scheduled'`
+
+const CLAIM_TICK = `
+UPDATE event_runs
+ SET claimed_by = ?, claim_expires_at = ?
+ WHERE id = ?
+ AND status IN ('starting','running','ending')
+ AND (claim_expires_at IS NULL OR claim_expires_at < ?)`
+
+const TRANSITION = `
+UPDATE event_runs SET status = ?, current_phase = ?
+ WHERE id = ? AND status IN (?)`
+
+const CLAIM_STEP = `
+UPDATE event_run_steps
+ SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?,
+ started_at = COALESCE(started_at, NOW())
+ WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`
+
+const STEP_GIVE_UP = `
+UPDATE event_run_steps
+ SET status = 'failed', last_error = 'gave up after repeated interruptions',
+ finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL
+ WHERE status = 'running'
+ AND claim_expires_at IS NOT NULL AND claim_expires_at < ?
+ AND attempts >= ?`
+
+const STEP_RECLAIM = `
+UPDATE event_run_steps
+ SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL
+ WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`
+
+const HOLD_NEXT = `
+UPDATE event_run_steps
+ SET due_at = ?
+ WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending'
+ AND (due_at IS NULL OR due_at < ?)
+ ORDER BY seq LIMIT 1`
+
+const MATERIALISE_RUN = `
+INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
+VALUES (?, ?, ?, ?, ?)`
+
+const MATERIALISE_STEP = `
+INSERT IGNORE INTO event_run_steps (run_id, phase, seq, action_id, idempotency_key)
+VALUES (?, ?, ?, ?, ?)`
+
+const FIND_MISSED = `
+SELECT r.id FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ WHERE r.status = 'scheduled'
+ AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?`
+
+const DB = `rg_events_test_${process.pid}`
+let pool = null
+let available = false
+
+const poolOpts = () => ({
+ host: process.env.DB_HOST || '127.0.0.1',
+ port: Number(process.env.DB_PORT) || 3306,
+ user: process.env.DB_USER || 'root',
+ password: process.env.DB_PASSWORD || '',
+})
+
+before(async () => {
+ const admin = mariadb.createPool({
+ ...poolOpts(),
+ connectionLimit: 1,
+ connectTimeout: 2000,
+ initializationTimeout: 2000,
+ multipleStatements: true,
+ })
+ try {
+ await admin.query(`CREATE DATABASE ${DB}`)
+ available = true
+ } catch {
+ available = false
+ } finally {
+ await admin.end().catch(() => {})
+ }
+ if (!available) return
+
+ pool = mariadb.createPool({
+ ...poolOpts(),
+ database: DB,
+ connectionLimit: 3,
+ multipleStatements: true,
+ bigIntAsNumber: true,
+ insertIdAsNumber: true,
+ })
+ await pool.query(SCHEMA)
+})
+
+after(async () => {
+ if (pool) {
+ await pool.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {})
+ await pool.end().catch(() => {})
+ }
+})
+
+// Checked INSIDE each test, never as a `{ skip }` option: the option is evaluated
+// when the file is read, which is before `before()` has had a chance to find out
+// whether there is a database. Every test skipped unconditionally is what that
+// mistake looks like, and it looks exactly like a passing suite.
+const SKIP = 'no database reachable - set DB_HOST/DB_PORT/DB_USER/DB_PASSWORD to run'
+const needDb = (t) => {
+ if (available) return false
+ t.skip(SKIP)
+ return true
+}
+
+const T0 = new Date('2026-09-02T12:00:00Z')
+const later = (ms) => new Date(T0.getTime() + ms)
+const rows = (r) => Number(r.affectedRows)
+
+beforeEach(async () => {
+ if (!available) return
+ await pool.query('DELETE FROM event_run_steps')
+ await pool.query('DELETE FROM event_runs')
+ await pool.query('DELETE FROM event_definitions')
+})
+
+async function seedRun(over = {}) {
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (?)', [
+ over.graceSeconds ?? 900,
+ ])
+ const r = await pool.query(
+ `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, concurrency_key,
+ claimed_by, claim_expires_at)
+ VALUES (?, 1, ?, ?, ?, ?, ?, ?)`,
+ [
+ def.insertId,
+ over.scope ?? '',
+ over.status ?? 'scheduled',
+ over.scheduledFor ?? T0,
+ over.concurrencyKey ?? null,
+ over.claimedBy ?? null,
+ over.claimExpiresAt ?? null,
+ ],
+ )
+ return { runId: r.insertId, definitionId: def.insertId }
+}
+
+const seedStep = async (runId, over = {}) =>
+ (
+ await pool.query(
+ `INSERT INTO event_run_steps (run_id, phase, seq, action_id, status, due_at, attempts,
+ claimed_by, claim_expires_at, idempotency_key)
+ VALUES (?, ?, ?, 'test.noop', ?, ?, ?, ?, ?, ?)`,
+ [
+ runId,
+ over.phase ?? 'main',
+ over.seq ?? 0,
+ over.status ?? 'pending',
+ over.dueAt ?? null,
+ over.attempts ?? 0,
+ over.claimedBy ?? null,
+ over.claimExpiresAt ?? null,
+ over.key ?? 'k'.repeat(40),
+ ],
+ )
+ ).insertId
+
+const stepById = async (id) => (await pool.query('SELECT * FROM event_run_steps WHERE id = ?', [id]))[0]
+const runById = async (id) => (await pool.query('SELECT * FROM event_runs WHERE id = ?', [id]))[0]
+
+// ── claimStart: exactly one winner ─────────────────────────────────────────
+
+test('claimStart: the first caller wins and every other gets zero', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun()
+
+ const first = await pool.query(CLAIM_START, ['host:1', later(60_000), runId])
+ const second = await pool.query(CLAIM_START, ['host:2', later(60_000), runId])
+
+ assert.equal(rows(first), 1, 'the winner is told 1')
+ assert.equal(rows(second), 0, 'the loser is told 0, not 1 with foundRows')
+ assert.equal((await runById(runId)).claimed_by, 'host:1')
+ assert.equal((await runById(runId)).status, 'starting')
+})
+
+test('claimStart: started_at is stamped once and never moved', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun()
+ await pool.query(CLAIM_START, ['host:1', later(60_000), runId])
+ const first = (await runById(runId)).started_at
+
+ await pool.query("UPDATE event_runs SET status = 'scheduled' WHERE id = ?", [runId])
+ await pool.query(CLAIM_START, ['host:2', later(60_000), runId])
+
+ assert.deepEqual((await runById(runId)).started_at, first, 'COALESCE keeps the original instant')
+})
+
+// ── claimTick: a live lease refuses even its own holder ────────────────────
+
+test('claimTick: a live lease is not re-enterable by the process that took it', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000) })
+
+ const again = await pool.query(CLAIM_TICK, ['host:1', later(120_000), runId, T0])
+ assert.equal(rows(again), 0, 'a tick that overran must not advance its own run twice')
+})
+
+test('claimTick: an expired lease is takeable, by anyone', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running', claimedBy: 'host:1', claimExpiresAt: later(-60_000) })
+
+ const taken = await pool.query(CLAIM_TICK, ['host:2', later(60_000), runId, T0])
+ assert.equal(rows(taken), 1)
+ assert.equal((await runById(runId)).claimed_by, 'host:2')
+})
+
+test('claimTick: a paused run is never claimable', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'paused' })
+ assert.equal(rows(await pool.query(CLAIM_TICK, ['host:1', later(60_000), runId, T0])), 0)
+})
+
+// ── transition: the guard is the whole point ───────────────────────────────
+
+test('transition: a run cancelled underneath the tick is not transitioned', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId])
+
+ const moved = await pool.query(TRANSITION, ['completed', 'main', runId, 'running'])
+ assert.equal(rows(moved), 0)
+ assert.equal((await runById(runId)).status, 'cancelled')
+})
+
+test('transition: running -> running is a guarded write, not a no-op', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+
+ // This is how the runner advances `current_phase`, and `foundRows` is exactly
+ // what makes it report 1 despite `status` not changing — which is the answer
+ // the caller needs, because what it is checking is that the run is STILL
+ // running, not that the status moved.
+ const moved = await pool.query(TRANSITION, ['running', 'closing', runId, 'running'])
+ assert.equal(rows(moved), 1)
+ assert.equal((await runById(runId)).current_phase, 'closing')
+})
+
+// ── The step claim ─────────────────────────────────────────────────────────
+
+test('the step claim: one winner, and attempts is incremented by the claim alone', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const stepId = await seedStep(runId)
+
+ assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(60_000), stepId, T0])), 1)
+ assert.equal(rows(await pool.query(CLAIM_STEP, ['host:2', later(60_000), stepId, T0])), 0)
+ assert.equal((await stepById(stepId)).attempts, 1, 'one claim, one attempt')
+})
+
+test('the step claim: a step held behind a core.wait is not due', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const stepId = await seedStep(runId, { dueAt: later(300_000) })
+
+ assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(60_000), stepId, T0])), 0)
+ assert.equal(rows(await pool.query(CLAIM_STEP, ['host:1', later(360_000), stepId, later(301_000)])), 1)
+})
+
+// ── The reclaim: order, and what it must not touch ─────────────────────────
+
+test('the reclaim hands a stale step back WITHOUT resetting attempts', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const stepId = await seedStep(runId, { status: 'running', attempts: 2, claimExpiresAt: later(-1000) })
+
+ await pool.query(STEP_GIVE_UP, [T0, 3])
+ await pool.query(STEP_RECLAIM, [T0])
+
+ const step = await stepById(stepId)
+ assert.equal(step.status, 'pending')
+ assert.equal(step.attempts, 2, 'Engagement Phase 14: a reclaim that reset this made MAX_ATTEMPTS unreachable')
+})
+
+test('the reclaim gives up FIRST, so a spent step leaves running as failed', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const stepId = await seedStep(runId, { status: 'running', attempts: 3, claimExpiresAt: later(-1000) })
+
+ const gaveUp = await pool.query(STEP_GIVE_UP, [T0, 3])
+ const reclaimed = await pool.query(STEP_RECLAIM, [T0])
+
+ assert.equal(rows(gaveUp), 1)
+ assert.equal(rows(reclaimed), 0, 'reversing these two makes the row retry forever')
+ assert.equal((await stepById(stepId)).status, 'failed')
+ assert.equal((await stepById(stepId)).attempts, 3)
+})
+
+test('the reclaim leaves a PARKED step alone, however long it waits', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const stepId = await seedStep(runId, { status: 'running', attempts: 1, claimExpiresAt: null })
+
+ await pool.query(STEP_GIVE_UP, [later(365 * 24 * 3600 * 1000), 3])
+ await pool.query(STEP_RECLAIM, [later(365 * 24 * 3600 * 1000)])
+
+ const step = await stepById(stepId)
+ assert.equal(step.status, 'running', 'a NULL lease is a parked cue, not staleness')
+ assert.equal(step.attempts, 1)
+})
+
+// ── holdNext ───────────────────────────────────────────────────────────────
+
+test('holdNext moves the next PENDING step of the phase, and only one', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ await seedStep(runId, { seq: 0, status: 'done' })
+ const second = await seedStep(runId, { seq: 1 })
+ const third = await seedStep(runId, { seq: 2 })
+
+ const moved = await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)])
+ assert.equal(rows(moved), 1)
+ assert.deepEqual((await stepById(second)).due_at, later(300_000))
+ assert.equal((await stepById(third)).due_at, null, 'a wait holds the next step, not the rest of the phase')
+})
+
+test('holdNext never pulls a due date earlier, so a re-dispatch cannot double the wait', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ await seedStep(runId, { seq: 0, status: 'done' })
+ const second = await seedStep(runId, { seq: 1, dueAt: later(600_000) })
+
+ const moved = await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)])
+ assert.equal(rows(moved), 0)
+ assert.deepEqual((await stepById(second)).due_at, later(600_000))
+})
+
+test('holdNext skips a step that is already running', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ await seedStep(runId, { seq: 0, status: 'done' })
+ await seedStep(runId, { seq: 1, status: 'running' })
+ const third = await seedStep(runId, { seq: 2 })
+
+ await pool.query(HOLD_NEXT, [later(300_000), runId, 'main', 0, later(300_000)])
+ assert.deepEqual((await stepById(third)).due_at, later(300_000))
+})
+
+// ── The two unique indexes that carry the weight ───────────────────────────
+
+test('uq_evrun_occurrence, not the claim, is what stops two runs of one occurrence', async (t) => {
+ if (needDb(t)) return
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
+
+ const a = await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])
+ const b = await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])
+
+ assert.equal(rows(a), 1)
+ assert.equal(rows(b), 0, 'INSERT IGNORE answers honestly rather than raising a 1062')
+ const all = await pool.query('SELECT COUNT(*) AS n FROM event_runs')
+ assert.equal(Number(all[0].n), 1)
+})
+
+test("scope '' rather than NULL is what makes that index work at all", async (t) => {
+ if (needDb(t)) return
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
+
+ // The empty-string case collides, as it must. A NULL scope would NOT: multiple
+ // NULLs do not collide in MariaDB, which would silently permit two runs of one
+ // occurrence — the reason the column is NOT NULL DEFAULT ''.
+ await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])
+ assert.equal(rows(await pool.query(MATERIALISE_RUN, [def.insertId, 1, '', T0, null])), 0)
+
+ // Two different scopes are two different occurrences, which is what lets a
+ // worldwide event fan out across servers without colliding with itself.
+ assert.equal(rows(await pool.query(MATERIALISE_RUN, [def.insertId, 1, 'atlantic', T0, null])), 1)
+})
+
+test('uq_evstep_slot makes re-materialising a phase a no-op', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+
+ assert.equal(rows(await pool.query(MATERIALISE_STEP, [runId, 'main', 0, 'test.noop', 'a'.repeat(40)])), 1)
+ assert.equal(rows(await pool.query(MATERIALISE_STEP, [runId, 'main', 0, 'test.noop', 'b'.repeat(40)])), 0)
+
+ const [step] = await pool.query('SELECT idempotency_key FROM event_run_steps WHERE run_id = ?', [runId])
+ assert.equal(step.idempotency_key, 'a'.repeat(40), 'a re-materialise cannot overwrite a key a dispatch already sent')
+})
+
+// ── The grace window is per definition ─────────────────────────────────────
+
+test('findMissed compares against each definition’s own grace window', async (t) => {
+ if (needDb(t)) return
+ const tight = await seedRun({ graceSeconds: 600 })
+ const generous = await seedRun({ graceSeconds: 3600, scope: 'b' })
+
+ const missed = (await pool.query(FIND_MISSED, [later(30 * 60 * 1000)])).map((r) => Number(r.id))
+ assert.deepEqual(missed, [tight.runId])
+ assert.ok(!missed.includes(generous.runId), 'inside its own window a run starts late rather than being missed')
+})
--
2.49.1
From 7b570c8ea1a6b269010e5a82f951e7f3aa905d11 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 2 Sep 2026 08:39:35 -0500
Subject: [PATCH 03/18] feat(events): the minimal admin surface (Phase 3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three screens, an Events nav group and the six live run controls Phase 1 left
absent on purpose because nothing was in flight. An admin can now author,
publish, start and watch an event that announces things and cues a human; a
moderator can stop one that is going wrong.
Six controls, not eight. `advance` is absent because a phase today advances when
its steps go terminal — the per-step skip already does that — and Phase 5 is what
gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`,
until Phase 8's ledger exists. Every control is a compare-and-set on the status it
may act from, so a console rendered thirty seconds ago cannot act on a run that
has moved.
Fixes a defect in the Phase 2 runner: `advanceRun` drained up to
EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the
tick, so a pause pressed mid-batch did nothing for up to 24 more steps.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
---
client/src/App.jsx | 15 +
client/src/api/client.js | 39 ++
client/src/lib/eventAuthoring.js | 296 +++++++++
client/src/routes/admin/AdminLayout.jsx | 21 +
client/src/routes/admin/views/EventEditor.jsx | 505 +++++++++++++++
client/src/routes/admin/views/EventRun.jsx | 352 +++++++++++
client/src/routes/admin/views/EventsAdmin.jsx | 260 ++++++++
client/test/eventAuthoring.test.js | 310 ++++++++++
server/routes.guards.json | 54 ++
server/routes.manifest.json | 24 +
.../model/events/eventRunControls.model.js | 296 +++++++++
server/src/model/events/eventRunSteps.db.js | 129 ++++
server/src/model/events/eventRuns.db.js | 26 +-
.../src/router/v1/admin/events.controller.js | 109 +++-
server/src/router/v1/admin/events.router.js | 111 +++-
server/src/utils/eventRunner.js | 8 +
server/swagger/swagger-output.json | 583 ++++++++++++++++++
server/test/eventRunControls.test.js | 442 +++++++++++++
server/test/eventRunner.test.js | 50 ++
server/test/eventRunnerSql.test.js | 153 +++++
20 files changed, 3775 insertions(+), 8 deletions(-)
create mode 100644 client/src/lib/eventAuthoring.js
create mode 100644 client/src/routes/admin/views/EventEditor.jsx
create mode 100644 client/src/routes/admin/views/EventRun.jsx
create mode 100644 client/src/routes/admin/views/EventsAdmin.jsx
create mode 100644 client/test/eventAuthoring.test.js
create mode 100644 server/src/model/events/eventRunControls.model.js
create mode 100644 server/test/eventRunControls.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 2b0e598..aef41a0 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -49,6 +49,9 @@ import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
+import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
+import EventEditor from './routes/admin/views/EventEditor.jsx'
+import EventRun from './routes/admin/views/EventRun.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -192,6 +195,18 @@ export default function App() {
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
} />
+ {/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
+ §K makes every read here `staff`, and the moderator's whole
+ power over this feature is the run console — cancelling a run
+ that is doing something wrong at 2am. The narrower gates are
+ applied per action instead: authoring is admin+editor, publish
+ and start are admin only (§N2), and each button follows the
+ route it calls. `runs/:runId` is declared before `:id` so the
+ literal segment is never read as a definition id. */}
+ } />
+ } />
+ } />
+ } />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 966adc4..a6e0a15 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -472,6 +472,45 @@ export const api = {
setEngagementRetention: (body) =>
req('/admin/engagement/retention', { method: 'PUT', body }),
+ // Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
+ // is admin+editor, publish and start are admin ONLY, and the six live
+ // controls are admin+moderator — the one gate in this feature wider than
+ // admin, because stopping a run at 2am is incident response and starting
+ // one is not (§N2). The buttons follow the same split, and the server
+ // re-checks every one of them.
+ listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
+ getEvent: (id) => req(`/admin/events/${id}`),
+ createEvent: (body) => req('/admin/events', { method: 'POST', body }),
+ updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
+ publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
+ archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
+ listEventVersions: (id) => req(`/admin/events/${id}/versions`),
+ eventCatalog: () => req('/admin/events/catalog'),
+ eventSeries: () => req('/admin/events/series'),
+ startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
+ listEventRuns: ({ definitionId, status, limit } = {}) => {
+ const qs = new URLSearchParams()
+ if (definitionId) qs.set('definitionId', String(definitionId))
+ if (status) qs.set('status', status)
+ if (limit) qs.set('limit', String(limit))
+ const suffix = qs.toString()
+ return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
+ },
+ getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
+ getEventRunLog: (runId, limit) =>
+ req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
+ pauseEventRun: (runId, reason) =>
+ req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
+ resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
+ cancelEventRun: (runId, reason) =>
+ req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }),
+ confirmEventStep: (runId, stepId, note) =>
+ req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
+ skipEventStep: (runId, stepId, reason) =>
+ req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
+ retryEventStep: (runId, stepId) =>
+ req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
+
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
new file mode 100644
index 0000000..8ce3179
--- /dev/null
+++ b/client/src/lib/eventAuthoring.js
@@ -0,0 +1,296 @@
+// ── What the three Events screens say, and what they let staff press ───────
+//
+// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server
+// decides what may be saved, and the six control statements decide what may
+// happen to a run — every one of them is a compare-and-set that re-checks the
+// status this file only *predicted*. What is here is the part that would be
+// wrong silently: a form that drops an authored step, a params box that posts a
+// string where the action declared an int, and above all a console that offers a
+// button the server is going to refuse.
+//
+// **The controls are modelled here rather than inline in the console for one
+// reason: they can be tested against the server's rules.** A button that 409s is
+// not a bug the way a wrong write is, but it is the failure mode an operator
+// meets at 2am while the thing they are trying to stop keeps running — so the
+// guards are written twice on purpose and the copy is checked.
+
+// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
+export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
+
+export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status)
+
+/** A step waiting on a human: `running`, with nothing holding it. */
+export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked)
+
+/**
+ * The highest `seq` of a step in this phase that is not still `pending` — the
+ * furthest the phase has got — or null when none of it has been attempted.
+ *
+ * The same rule as the server's `lastStartedSeq`, over the step list the console
+ * already has, and used only to decide whether to OFFER retry. The near miss is
+ * worth keeping in view: "the lowest step that is not finished" looks like the
+ * same thing and is not, because the runner steps OVER a failed step. Under that
+ * rule a phase that carried on past an `on_failure: skip` failure and then paused
+ * at a later one would offer retry on the wrong step.
+ */
+export function lastStartedSeqOf(steps, phase) {
+ const started = (steps || [])
+ .filter((s) => s.phase === phase && s.status !== 'pending')
+ .map((s) => Number(s.seq))
+ return started.length ? Math.max(...started) : null
+}
+
+/**
+ * Which run-level controls to offer.
+ *
+ * `pause` is `starting`/`running` only: a `scheduled` occurrence that should not
+ * happen is cancelled, not paused. `cancel` is everything non-terminal — "this
+ * is not happening" is a decision made before a run starts as often as during
+ * one.
+ */
+export function runControlsFor(run) {
+ if (!run) return { pause: false, resume: false, cancel: false }
+ const terminal = isTerminalRun(run.status)
+ return {
+ pause: ['starting', 'running'].includes(run.status),
+ resume: run.status === 'paused',
+ cancel: !terminal,
+ }
+}
+
+/**
+ * Which step-level controls to offer, for one step of one run.
+ *
+ * `retry` carries the guard worth restating: only while the run is PAUSED, only
+ * on a `failed` step of the phase the run is currently in, and only when that
+ * step is the furthest one the phase has reached. A failed step under an
+ * `on_failure` of `skip` is one the run has already moved past, and re-queueing
+ * it would put a pending row behind the runner's cursor, where it would sit for
+ * ever.
+ */
+export function stepControlsFor(run, step, steps) {
+ const none = { confirm: false, skip: false, retry: false }
+ if (!run || !step) return none
+ if (isTerminalRun(run.status)) return none
+
+ const parked = isParked(step)
+ const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null
+
+ return {
+ confirm: parked,
+ skip: parked || step.status === 'pending',
+ retry:
+ run.status === 'paused' &&
+ step.status === 'failed' &&
+ step.phase === run.currentPhase &&
+ furthest !== null &&
+ Number(furthest) === Number(step.seq),
+ }
+}
+
+// ── The definition form ────────────────────────────────────────────────────
+
+export const BLANK_PHASE_KEY = 'phase'
+
+const nextPhaseKey = (phases) => {
+ const used = new Set((phases || []).map((p) => p.key))
+ for (let n = 1; n < 100; n++) {
+ const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}`
+ if (!used.has(key)) return key
+ }
+ return `${BLANK_PHASE_KEY}-${Date.now()}`
+}
+
+/**
+ * A new step, with its params box PREFILLED from the action's declared examples.
+ *
+ * Every param carries a required `example` — that requirement is the reason this
+ * works — so a fresh `core.announce` step arrives as a JSON object with the right
+ * keys and plausible values rather than as an empty `{}` an author has to guess
+ * the shape of. It is the nearest a raw JSON box gets to the schema-driven form
+ * Phase 13 replaces it with, and it costs nothing the catalog was not already
+ * serving.
+ */
+export function blankStep(action) {
+ const params = {}
+ for (const p of action?.params || []) {
+ if (p.required || p.example !== undefined) params[p.name] = p.example
+ }
+ return {
+ actionId: action?.id || '',
+ label: action?.label || '',
+ onFailure: '',
+ paramsText: JSON.stringify(params, null, 2),
+ }
+}
+
+export function blankPhase(phases) {
+ return { key: nextPhaseKey(phases), label: 'New phase', steps: [] }
+}
+
+/** The editor's working state, from what `GET /admin/events/:id` returned. */
+export function formFromDefinition(event) {
+ const spec = event?.spec || {}
+ return {
+ title: event?.title || '',
+ summary: event?.summary || '',
+ body: event?.body || '',
+ imageUrl: event?.imageUrl || '',
+ seriesId: event?.seriesId ? String(event.seriesId) : '',
+ seriesOrder: event?.seriesOrder ?? 0,
+ concurrencyKey: event?.concurrencyKey || '',
+ graceSeconds: event?.graceSeconds ?? 900,
+ timezone: event?.timezone || 'UTC',
+ scheduleKind: spec.schedule?.kind || 'manual',
+ phases: (spec.phases || []).map((p) => ({
+ key: p.key || '',
+ label: p.label || '',
+ steps: (p.steps || []).map((s) => ({
+ actionId: s.actionId || '',
+ label: s.label || '',
+ onFailure: s.onFailure || '',
+ dormant: Boolean(s.dormant),
+ actionVersion: s.actionVersion,
+ paramsText: JSON.stringify(s.params || {}, null, 2),
+ })),
+ })),
+ }
+}
+
+/**
+ * The form, as a request body — or the list of everything wrong with it.
+ *
+ * Only the JSON parse is checked here, and only because a params box whose text
+ * is not JSON cannot be turned into a request at all. **Everything else is left
+ * to the server**: unknown params, wrong types, missing required ones, bad phase
+ * keys and duplicate keys all come back from `POST`/`PUT` as a list, and
+ * re-deciding any of them here would be a second validator drifting from the one
+ * that matters.
+ *
+ * `onFailure` is omitted when the author has not chosen one, so the server
+ * applies the action's risk-class default rather than being told a value the
+ * form invented.
+ */
+export function payloadFromForm(form) {
+ const errors = []
+ const phases = (form.phases || []).map((phase, pi) => ({
+ key: phase.key,
+ label: phase.label,
+ steps: (phase.steps || []).map((step, si) => {
+ const out = { actionId: step.actionId }
+ if (step.label) out.label = step.label
+ if (step.onFailure) out.onFailure = step.onFailure
+ const parsed = parseParams(step.paramsText)
+ if (parsed.error) {
+ errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`)
+ } else {
+ out.params = parsed.params
+ }
+ return out
+ }),
+ }))
+
+ if (errors.length) return { ok: false, errors }
+
+ return {
+ ok: true,
+ payload: {
+ title: form.title,
+ summary: form.summary || null,
+ body: form.body || null,
+ imageUrl: form.imageUrl || null,
+ seriesId: form.seriesId ? Number(form.seriesId) : null,
+ seriesOrder: Number(form.seriesOrder) || 0,
+ concurrencyKey: form.concurrencyKey || null,
+ graceSeconds: Number(form.graceSeconds),
+ timezone: form.timezone,
+ spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases },
+ },
+ }
+}
+
+/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
+export function parseParams(text) {
+ const raw = (text || '').trim()
+ if (!raw) return { params: {} }
+ let value
+ try {
+ value = JSON.parse(raw)
+ } catch (err) {
+ return { error: `the params are not valid JSON (${err.message})` }
+ }
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+ return { error: 'the params must be a JSON object' }
+ }
+ return { params: value }
+}
+
+// ── Rendering what happened ────────────────────────────────────────────────
+
+const STATUS_WORDS = {
+ scheduled: 'Scheduled',
+ starting: 'Starting',
+ running: 'Running',
+ paused: 'Paused',
+ ending: 'Winding down',
+ completed: 'Completed',
+ cancelled: 'Cancelled',
+ failed: 'Failed',
+ missed: 'Missed',
+}
+
+export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown'
+
+const KIND_WORDS = {
+ 'run.created': 'Occurrence created',
+ 'run.status': 'Run status',
+ 'run.health': 'Health',
+ 'run.blocked': 'Held off',
+ 'phase.entered': 'Phase entered',
+ 'phase.completed': 'Phase completed',
+ 'step.status': 'Step',
+ 'step.retry': 'Step retried',
+ 'step.parked': 'Waiting on a human',
+ note: 'Note',
+}
+
+export const logKindWord = (kind) => KIND_WORDS[kind] || kind
+
+/**
+ * One log line as a sentence.
+ *
+ * The `detail` of a human control carries `control` and `by`, which is what
+ * separates "the runner paused this because a world write failed" from "somebody
+ * pressed pause" — the two are the same transition and the console has to be
+ * able to tell them apart at a glance.
+ */
+export function describeLogLine(line) {
+ const d = line?.detail || {}
+ const by = d.by ? ' by staff' : ''
+ switch (line?.kind) {
+ case 'run.status':
+ return d.control
+ ? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}`
+ : `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}`
+ case 'run.health':
+ return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}`
+ case 'run.blocked':
+ return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"`
+ case 'phase.entered':
+ return `Entered ${line.phase} (${d.steps ?? '?'} steps)`
+ case 'phase.completed':
+ return `${line.phase} finished`
+ case 'step.parked':
+ return `${d.action} is waiting on a human`
+ case 'step.retry':
+ return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
+ case 'step.status':
+ return d.control
+ ? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
+ : `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
+ case 'run.created':
+ return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
+ default:
+ return logKindWord(line?.kind)
+ }
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 3010eac..d489c5e 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -53,6 +53,7 @@ const IconList = () =>
const IconSpark = () =>
const IconLog = () =>
+const IconCalendar = () =>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -120,6 +121,20 @@ export const NAV = [
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
],
},
+ {
+ // Its own top-level group rather than a row under Content, and staff-wide
+ // rather than admin-only. Both follow EVENTS.md §K: every read here is
+ // `staff`, and the moderator's entire power over this feature is the run
+ // console — the thing they open when an event is doing something wrong at
+ // 2am. Hiding it from them would leave the one role that exists for incident
+ // response unable to see the incident. The narrower gates live on the
+ // actions: authoring is admin+editor and publish/start are admin only, both
+ // enforced server-side and mirrored on the buttons.
+ title: 'Events',
+ items: [
+ { to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
+ ],
+ },
{
title: 'System',
items: [
@@ -202,6 +217,8 @@ const TITLES = {
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
+ '/admin/events': 'Events',
+ '/admin/events/new': 'New event',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
@@ -222,6 +239,10 @@ function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
+ // /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
+ // belong to the same section as far as the page title is concerned.
+ if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
+ if (pathname.startsWith('/admin/events/')) return 'Event'
return 'Admin'
}
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
new file mode 100644
index 0000000..3ab4d99
--- /dev/null
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -0,0 +1,505 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { useAuth } from '../../../contexts/AuthContext.jsx'
+import { api } from '../../../api/client.js'
+import {
+ formFromDefinition,
+ payloadFromForm,
+ blankPhase,
+ blankStep,
+} from '../../../lib/eventAuthoring.js'
+
+// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
+//
+// **A vertical timeline, not a node graph**, and that is a decision about what
+// the engine can actually do rather than a matter of taste. The condition
+// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at
+// depth five — so a canvas would promise power this project has never handed an
+// operator. Phases in order, each with its steps in order, says exactly what the
+// runner does with them.
+//
+// **Core renders no game word here.** Every label on a step comes from the
+// action's own registration — its `label`, its params' names, their descriptions
+// and their examples — so an installed module's vocabulary appears without core
+// knowing any of it, and `check:modules` already fails core's build on a UO
+// identifier.
+//
+// **The params box is a raw JSON field and it is captioned as a placeholder**,
+// because that is what it is: Phase 13 replaces it with the schema-driven form
+// the condition builder already models. What makes it usable in the meantime is
+// that a new step arrives PREFILLED from the action's declared examples, and the
+// declaration is rendered beside the box — every param's name, type, whether it
+// is required, and what a value looks like. All of that was already in the
+// catalog; none of it is a second copy of anything.
+
+const DORMANT_NOTE =
+ 'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
+
+export default function EventEditor() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const isNew = id === 'new'
+
+ const [form, setForm] = useState(null)
+ const [event, setEvent] = useState(null)
+ const [catalog, setCatalog] = useState(null)
+ const [series, setSeries] = useState([])
+ const [versions, setVersions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [problems, setProblems] = useState([])
+ const [notice, setNotice] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const isAdmin = user?.role === 'admin'
+ // Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
+ // — the run console is their whole job and a definition is what a run is OF. But
+ // authoring is `admin` + `editor`, so Save has to follow the route it calls.
+ // Offering it and letting the server answer 403 is the shape §K calls "a gate
+ // nobody notices was missing", only inverted: a button that does nothing.
+ const mayAuthor = isAdmin || user?.role === 'editor'
+
+ const load = useCallback(async () => {
+ const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()])
+ setCatalog(cat)
+ setSeries(ser.series || [])
+ if (isNew) {
+ setEvent(null)
+ setVersions([])
+ setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
+ return
+ }
+ const [{ event: loaded }, { versions: history }] = await Promise.all([
+ api.admin.getEvent(id),
+ api.admin.listEventVersions(id),
+ ])
+ setEvent(loaded)
+ setVersions(history || [])
+ setForm(formFromDefinition(loaded))
+ }, [id, isNew])
+
+ useEffect(() => {
+ let alive = true
+ ;(async () => {
+ setLoading(true)
+ try {
+ await load()
+ if (alive) setError(null)
+ } catch (err) {
+ if (alive) setError(err.message)
+ } finally {
+ if (alive) setLoading(false)
+ }
+ })()
+ return () => {
+ alive = false
+ }
+ }, [load])
+
+ const actions = useMemo(() => catalog?.actions || [], [catalog])
+ const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
+
+ const set = (patch) => setForm((f) => ({ ...f, ...patch }))
+
+ const setPhase = (pi, patch) =>
+ setForm((f) => ({
+ ...f,
+ phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
+ }))
+
+ const setStep = (pi, si, patch) =>
+ setForm((f) => ({
+ ...f,
+ phases: f.phases.map((p, i) =>
+ i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
+ ),
+ }))
+
+ const movePhase = (pi, delta) =>
+ setForm((f) => {
+ const next = [...f.phases]
+ const to = pi + delta
+ if (to < 0 || to >= next.length) return f
+ ;[next[pi], next[to]] = [next[to], next[pi]]
+ return { ...f, phases: next }
+ })
+
+ const moveStep = (pi, si, delta) =>
+ setForm((f) => ({
+ ...f,
+ phases: f.phases.map((p, i) => {
+ if (i !== pi) return p
+ const steps = [...p.steps]
+ const to = si + delta
+ if (to < 0 || to >= steps.length) return p
+ ;[steps[si], steps[to]] = [steps[to], steps[si]]
+ return { ...p, steps }
+ }),
+ }))
+
+ /**
+ * Changing a step's action REPLACES its params with the new action's examples.
+ *
+ * The alternative — keeping what was typed — leaves an object whose keys belong
+ * to a different action, and the save refuses it with "x is not a param of y"
+ * for every one of them. Replacing is the honest move and it is not
+ * destructive in any way an author minds: they have just said this step does
+ * something else.
+ */
+ const changeAction = (pi, si, actionId) => {
+ const action = actionById.get(actionId)
+ const fresh = blankStep(action)
+ setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' })
+ }
+
+ const save = async () => {
+ setBusy(true)
+ setProblems([])
+ setNotice(null)
+ const built = payloadFromForm(form)
+ if (!built.ok) {
+ setProblems(built.errors)
+ setBusy(false)
+ return
+ }
+ try {
+ if (isNew) {
+ const result = await api.admin.createEvent(built.payload)
+ navigate(`/admin/events/${result.event.id}`, { replace: true })
+ } else {
+ const result = await api.admin.updateEvent(id, built.payload)
+ setEvent(result.event)
+ setForm(formFromDefinition(result.event))
+ setNotice('Saved.')
+ }
+ } catch (err) {
+ // The server answers with every problem rather than the first, so an author
+ // fixing a spec does it in one pass rather than six round trips.
+ setProblems(err.body?.errors || [err.message])
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const publish = async () => {
+ setBusy(true)
+ setProblems([])
+ setNotice(null)
+ try {
+ const result = await api.admin.publishEvent(id)
+ setEvent(result.event)
+ setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
+ setNotice(`Published as v${result.version}.`)
+ } catch (err) {
+ setProblems(err.body?.errors || [err.message])
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const start = async () => {
+ setBusy(true)
+ setProblems([])
+ try {
+ const result = await api.admin.startEventRun(id, {})
+ navigate(`/admin/events/runs/${result.run.id}`)
+ } catch (err) {
+ setProblems(err.body?.errors || [err.message])
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (loading || !form) return
+ if (error) return
+
+ const archived = event?.state === 'archived'
+
+ return (
+
+
+
+
+ {isNew ? 'New event' : event?.title}
+
+
+ {isNew ? (
+ 'The slug is derived from the title once and frozen afterwards — the public event page lives at it.'
+ ) : (
+ <>
+ {event?.slug} · {event?.state}
+ {event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'}
+ >
+ )}
+
+
+
+ {mayAuthor && (
+
+ )}
+ {/* Publish and start are admin ONLY (§N2) and not the same gate as the
+ live controls: publishing commits a definition a schedule will later
+ start unattended. */}
+ {!isNew && isAdmin && (
+
+ )}
+ {!isNew && isAdmin && event?.state === 'ready' && (
+
+ )}
+
+
+
+ {!mayAuthor && (
+
+ You can read this definition and watch its runs. Editing and publishing an event are an
+ admin or editor's, and starting one is an admin's alone — live control of a run already in
+ flight is yours.
+
+ )}
+
+ {archived && (
+
+ This definition is archived. It is kept so its past runs can still be explained, and it
+ cannot be edited or run again.
+
+ )}
+
+ {notice &&
{notice}
}
+
+ {problems.length > 0 && (
+
+
That did not save:
+
+ {problems.map((p) =>
{p}
)}
+
+
+ )}
+
+ {/* ── Basics ── */}
+
+
+
+
+
+
+
+
+
+
+
+ The grace window is how late this event may still start: past it an occurrence becomes
+ missed rather than beginning hours after it was announced. Two runs sharing a
+ concurrency key never overlap — {'{placeholders}'} are filled from the run’s own
+ params.
+
+
+
+ {/* ── Schedule ── */}
+
+
Schedule
+
+ Started by hand. Recurrence — once, weekly, monthly on the nth weekday —
+ is computed in the event’s own timezone and arrives in the next phase, with the calendar.
+ Until then an occurrence exists because somebody pressed Start now, and the
+ schedule shape a definition may carry is deliberately the single one the runner honours.
+
+
+
+ {/* ── The phase timeline ── */}
+
+
Phases
+
+
+
+ {form.phases.length === 0 && (
+
+ No phases yet. An event needs at least one phase with at least one step before it can be
+ published.
+
+ )}
+
+ {form.phases.map((phase, pi) => (
+
+
+
+
+
+
+
+
+
+
+
+ The key is what the run console groups by and what “phase 3 has not started” names, so it
+ cannot change once runs exist. A phase advances when every one of its steps is finished;
+ advancing on a condition instead is a later phase.
+
+ Pick an action and its parameters are listed here, straight from what the
+ module declared.
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+
+ ))}
+
+ {!isNew && versions.length > 0 && (
+
+
Versions
+
+ Publishing snapshots the whole spec into a version nothing ever edits. Editing this
+ definition while a run is live is free — the live run keeps the version it
+ pinned and is unaffected by anything on this screen.
+
+
+
+ {versions.map((v) => (
+
+
v{v.version}{v.current && · current}
+
{new Date(v.publishedAt).toLocaleString()}
+
{v.publishedByUsername || '—'}
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx
new file mode 100644
index 0000000..c82b020
--- /dev/null
+++ b/client/src/routes/admin/views/EventRun.jsx
@@ -0,0 +1,352 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { api } from '../../../api/client.js'
+import {
+ runStatusWord,
+ isTerminalRun,
+ isParked,
+ runControlsFor,
+ stepControlsFor,
+ describeLogLine,
+} from '../../../lib/eventAuthoring.js'
+
+// Admin → Events → the run console (EVENTS.md §I, Phase 3).
+//
+// One run: where it is, what each of its steps did, what a human can still do
+// about it, and the diagnostic log underneath. Staff-wide to read; the six
+// controls are `admin` + `moderator`, and the server re-checks every one of them
+// against the run's live status — this screen predicts, it does not decide.
+//
+// **It polls rather than streaming.** A run changes on the runner's tick, which
+// is a fifteen-second clock, and a console watched for the length of an event is
+// a tab left open for two hours: an SSE channel for that is a connection held
+// per staff member for a screen that could not use the latency. The poll stops
+// the moment the run reaches a terminal status, because a completed run has
+// nothing further to say.
+//
+// **The parked step is the thing this screen exists to make impossible to
+// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
+// stay that way for ever unless somebody presses confirm. It is called out above
+// the step list rather than being one row in it.
+
+const POLL_MS = 5000
+
+const STATUS_COLOR = {
+ failed: '#d98b84',
+ missed: '#d98b84',
+ paused: '#d9c184',
+ cancelled: 'var(--muted)',
+ running: '#8fc79a',
+ completed: '#8fc79a',
+}
+
+const STEP_COLOR = {
+ done: '#8fc79a',
+ failed: '#d98b84',
+ refused: '#d9c184',
+ skipped: 'var(--muted)',
+ cancelled: 'var(--muted)',
+}
+
+const when = (v) => (v ? new Date(v).toLocaleString() : '—')
+const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
+
+export default function EventRun() {
+ const { runId } = useParams()
+ const [run, setRun] = useState(null)
+ const [steps, setSteps] = useState([])
+ const [counts, setCounts] = useState({})
+ const [lines, setLines] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [problem, setProblem] = useState(null)
+ const [notes, setNotes] = useState({})
+ const [reason, setReason] = useState('')
+ const alive = useRef(true)
+
+ const load = useCallback(async () => {
+ const [detail, log] = await Promise.all([
+ api.admin.getEventRun(runId),
+ api.admin.getEventRunLog(runId, 200),
+ ])
+ if (!alive.current) return
+ setRun(detail.run)
+ setSteps(detail.steps || [])
+ setCounts(detail.counts || {})
+ setLines(log.log || [])
+ }, [runId])
+
+ useEffect(() => {
+ alive.current = true
+ ;(async () => {
+ setLoading(true)
+ try {
+ await load()
+ setError(null)
+ } catch (err) {
+ if (alive.current) setError(err.message)
+ } finally {
+ if (alive.current) setLoading(false)
+ }
+ })()
+ return () => {
+ alive.current = false
+ }
+ }, [load])
+
+ // The poll, and its own off switch. A terminal run is not re-read: it cannot
+ // change, and a console left open on last night's completed event should not
+ // be a request every five seconds until the tab is closed.
+ useEffect(() => {
+ if (!run || isTerminalRun(run.status)) return undefined
+ const timer = setInterval(() => {
+ load().catch(() => {})
+ }, POLL_MS)
+ return () => clearInterval(timer)
+ }, [run, load])
+
+ /** Every control goes through here: press, reload, and surface a refusal. */
+ const act = async (fn) => {
+ setBusy(true)
+ setProblem(null)
+ try {
+ await fn()
+ await load()
+ } catch (err) {
+ // A 409 is the ordinary answer to a button pressed against a run that has
+ // moved on since the screen was drawn, so it is shown as a sentence rather
+ // than as an error state — and the reload above has already re-drawn the
+ // controls as they now stand.
+ setProblem(err.body?.errors?.[0] || err.message)
+ await load().catch(() => {})
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (loading && !run) return
+ if (error) return
+ if (!run) return
+
+ const controls = runControlsFor(run)
+ const parked = steps.filter(isParked)
+ const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
+
+ return (
+
+
+
+ {/* Health is not status, which is the whole reason the two are separate
+ columns — but the sentence has to agree with the status it sits beside.
+ A degraded RUNNING run is the interesting case: still going, already in
+ trouble. A degraded PAUSED run is not "still running", and saying so on
+ the one screen an operator opens to find out what stopped it would be
+ the console contradicting itself. Found in the browser walk. */}
+ {run.health === 'degraded' && !isTerminalRun(run.status) && (
+
+ {run.status === 'paused' ? (
+ <>
+ Something in this run failed, and it is waiting for a person. Resuming carries the phase
+ past the failed step; Retry & resume puts that step back in the queue first.
+ >
+ ) : (
+ <>
+ Something in this run has already had to be retried. It is still running — this is
+ what “degraded” means, and the log below says what happened.
+ >
+ )}
+
+ )}
+
+ {run.lastError && (
+
{run.lastError}
+ )}
+
+ {problem && (
+
{problem}
+ )}
+
+ {/* ── The run controls ── */}
+
+
+
+
+
+
+
+ {isTerminalRun(run.status) && (
+
+ This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
+ — a run pins the version it started from so that it can still be explained later.
+
+ )}
+
+ {/* ── Waiting on a person ── */}
+ {parked.length > 0 && (
+
+
Waiting on a person
+
+ Nothing else in this phase runs until each of these is confirmed. There is no timeout —
+ a cue posted on Friday is still waiting on Monday.
+
+ Steps run strictly in order within a phase, and the phase ends when every one of them has
+ finished. A failed step is not retried by the runner past its attempt limit — resuming a
+ paused run carries the phase past it, and Retry & resume puts the step the run is
+ stopped at back in the queue.
+
+
+ {/* ── The log ── */}
+
Log
+
+ The run’s own diagnostic record, newest first — this is what answers “why didn’t phase 3
+ start?” without reading server logs. Who published or started what is recorded separately, in
+ the activity log.
+
+
+
+
+ {lines.map((line) => (
+
+
{clock(line.at)}
+
{line.phase || ''}
+
{describeLogLine(line)}
+
+ ))}
+ {lines.length === 0 &&
Nothing logged yet.
}
+
+
+
+
+ )
+}
diff --git a/client/src/routes/admin/views/EventsAdmin.jsx b/client/src/routes/admin/views/EventsAdmin.jsx
new file mode 100644
index 0000000..1153211
--- /dev/null
+++ b/client/src/routes/admin/views/EventsAdmin.jsx
@@ -0,0 +1,260 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { useAuth } from '../../../contexts/AuthContext.jsx'
+import { api } from '../../../api/client.js'
+import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
+
+// Admin → Events (EVENTS.md §I, Phase 3).
+//
+// Two tables on one screen: the definitions an operator authors, and the runs
+// those definitions have produced. They are together rather than on two nav rows
+// because the question this screen exists to answer is one question — "what is
+// scheduled, and what is happening right now" — and the second half of it is the
+// one somebody opens at 8pm on a Friday.
+//
+// **The waiting badge is the whole reason the run table is here rather than
+// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
+// `running`, nothing has failed, and it will stay that way for ever because it
+// is waiting for a person who does not know they are being waited for. The count
+// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
+// it before it can say so.
+//
+// What is NOT here: a calendar. Recurrence and the month view are Phase 4, and a
+// definition today can only carry `schedule: { kind: 'manual' }` — so the honest
+// list is a list, and the screen says as much rather than showing an empty grid.
+
+const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
+
+const STATUS_COLOR = {
+ failed: '#d98b84',
+ missed: '#d98b84',
+ paused: '#d9c184',
+ cancelled: 'var(--muted)',
+ running: '#8fc79a',
+}
+
+const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
+
+const when = (value) => (value ? new Date(value).toLocaleString() : '—')
+
+export default function EventsAdmin() {
+ const { user } = useAuth()
+ const navigate = useNavigate()
+ const [events, setEvents] = useState([])
+ const [runs, setRuns] = useState([])
+ const [state, setState] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [notice, setNotice] = useState(null)
+
+ const isAdmin = user?.role === 'admin'
+ const mayAuthor = isAdmin || user?.role === 'editor'
+
+ const load = useCallback(async (nextState) => {
+ const [defs, runList] = await Promise.all([
+ api.admin.listEvents(nextState || undefined),
+ api.admin.listEventRuns({ limit: 50 }),
+ ])
+ setEvents(defs.events || [])
+ setRuns(runList.runs || [])
+ }, [])
+
+ useEffect(() => {
+ let alive = true
+ ;(async () => {
+ setLoading(true)
+ try {
+ await load(state)
+ if (alive) setError(null)
+ } catch (err) {
+ if (alive) setError(err.message)
+ } finally {
+ if (alive) setLoading(false)
+ }
+ })()
+ return () => {
+ alive = false
+ }
+ }, [load, state])
+
+ // "Start now" is an occurrence whose instant is the present, not a separate
+ // concept — the same route a scheduled occurrence will use in Phase 4. Admin
+ // only, deliberately (§N2): starting commits the deployment to everything the
+ // definition contains, unattended.
+ const startNow = async (event) => {
+ setBusy(true)
+ setNotice(null)
+ try {
+ const result = await api.admin.startEventRun(event.id, {})
+ navigate(`/admin/events/runs/${result.run.id}`)
+ } catch (err) {
+ setNotice(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (loading && !events.length && !runs.length) return
+ if (error) return
+
+ const live = runs.filter((r) => !isTerminalRun(r.status))
+ const waiting = live.filter((r) => r.waitingSteps > 0)
+
+ return (
+
+
+
+ Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
+ published as an immutable version, and every occurrence of it runs against the version it
+ pinned. Recurrence and the calendar arrive with the next phase — for now an occurrence is
+ started by hand.
+
+
+
+ {mayAuthor && (
+
+ New event
+
+ )}
+
+
+
+ {notice && (
+
{notice}
+ )}
+
+ {waiting.length > 0 && (
+
+
+ {waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
+ person.{' '}
+
+ A cue holds its phase until somebody confirms it was done in-client — nothing else will
+ move it.
+
+
+ {/* Start is admin only and the button follows the route: an
+ editor sees the definition and cannot commit the
+ deployment to running it. */}
+ {isAdmin && e.state === 'ready' && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
new file mode 100644
index 0000000..a1e11c4
--- /dev/null
+++ b/client/test/eventAuthoring.test.js
@@ -0,0 +1,310 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ runControlsFor,
+ stepControlsFor,
+ isParked,
+ lastStartedSeqOf,
+ formFromDefinition,
+ payloadFromForm,
+ parseParams,
+ blankStep,
+ blankPhase,
+ describeLogLine,
+ runStatusWord,
+} from '../src/lib/eventAuthoring.js'
+
+// lib/eventAuthoring.js — what the three Events screens say and what they let
+// staff press (EVENTS.md §I, Phase 3).
+//
+// None of this is a boundary: `events/spec.js` decides what may be saved and the
+// six control statements decide what may happen to a run, each of them a
+// compare-and-set that re-checks the status this file only predicted.
+//
+// **The controls get most of the tests, and the reason is worth stating.** A
+// button offered that the server refuses is not a wrong write — but it is the
+// failure an operator meets at 2am, on the screen they opened because something
+// is already going wrong, about the run they are trying to stop. So the guards
+// are deliberately written twice and this is where the copy is checked against
+// the original.
+
+const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over })
+const step = (over = {}) => ({
+ id: 10,
+ phase: 'main',
+ seq: 0,
+ status: 'pending',
+ parked: false,
+ ...over,
+})
+
+// ── The run controls ───────────────────────────────────────────────────────
+
+test('pause is offered only for a run in flight', () => {
+ assert.equal(runControlsFor(run({ status: 'running' })).pause, true)
+ assert.equal(runControlsFor(run({ status: 'starting' })).pause, true)
+ // A scheduled occurrence that should not happen is cancelled, not paused:
+ // resuming one after its grace window would produce a `missed` from a button
+ // labelled resume.
+ assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false)
+ assert.equal(runControlsFor(run({ status: 'paused' })).pause, false)
+})
+
+test('cancel is offered right up to the moment a run goes terminal, and never after', () => {
+ for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
+ assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`)
+ }
+ for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
+ assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`)
+ }
+})
+
+test('resume is offered for exactly one status', () => {
+ assert.equal(runControlsFor(run({ status: 'paused' })).resume, true)
+ assert.equal(runControlsFor(run({ status: 'running' })).resume, false)
+})
+
+// ── The step controls ──────────────────────────────────────────────────────
+
+test('a parked step is running with nothing holding it, and only that', () => {
+ assert.equal(isParked(step({ status: 'running', parked: true })), true)
+ assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch')
+ assert.equal(isParked(step({ status: 'pending', parked: true })), false)
+})
+
+test('confirm is offered for a parked cue and for nothing else', () => {
+ const r = run()
+ const parked = step({ status: 'running', parked: true })
+ assert.equal(stepControlsFor(r, parked, [parked]).confirm, true)
+
+ const dispatching = step({ status: 'running', parked: false })
+ assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false)
+
+ const pending = step()
+ assert.equal(stepControlsFor(r, pending, [pending]).confirm, false)
+})
+
+test('skip is offered for a pending step and a parked cue', () => {
+ const r = run()
+ const pending = step()
+ const parked = step({ id: 11, seq: 1, status: 'running', parked: true })
+ const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false })
+ const failed = step({ id: 13, seq: 3, status: 'failed' })
+ const steps = [pending, parked, dispatching, failed]
+
+ assert.equal(stepControlsFor(r, pending, steps).skip, true)
+ assert.equal(stepControlsFor(r, parked, steps).skip, true)
+ assert.equal(stepControlsFor(r, dispatching, steps).skip, false)
+ // A failed step does not need skipping: the runner already steps over it, so
+ // resuming the run carries the phase past it.
+ assert.equal(stepControlsFor(r, failed, steps).skip, false)
+})
+
+test('retry is offered for the failed step a paused run is stopped at', () => {
+ const r = run({ status: 'paused' })
+ const done = step({ id: 1, seq: 0, status: 'done' })
+ const failed = step({ id: 2, seq: 1, status: 'failed' })
+ const pending = step({ id: 3, seq: 2, status: 'pending' })
+ const steps = [done, failed, pending]
+
+ assert.equal(stepControlsFor(r, failed, steps).retry, true)
+ assert.equal(stepControlsFor(r, done, steps).retry, false)
+ assert.equal(stepControlsFor(r, pending, steps).retry, false)
+})
+
+test('retry is NOT offered for a failed step the run has moved past', () => {
+ // The case the server guard exists for, and the one this copy of it has to
+ // agree about: a phase that carried on past an `on_failure: skip` failure and
+ // then paused at a later step. Offering retry on the first would re-queue a row
+ // behind the runner's own cursor, where it sits pending for ever.
+ const r = run({ status: 'paused' })
+ const skippedOver = step({ id: 1, seq: 0, status: 'failed' })
+ const carriedOn = step({ id: 2, seq: 1, status: 'done' })
+ const stoppedAt = step({ id: 3, seq: 2, status: 'failed' })
+ const notYet = step({ id: 4, seq: 3, status: 'pending' })
+ const steps = [skippedOver, carriedOn, stoppedAt, notYet]
+
+ assert.equal(stepControlsFor(r, skippedOver, steps).retry, false)
+ assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true)
+})
+
+test('retry is not offered while the run is still running, or in a phase it has left', () => {
+ const failed = step({ status: 'failed' })
+ assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false)
+
+ const old = step({ phase: 'one', status: 'failed' })
+ const r = run({ status: 'paused', currentPhase: 'two' })
+ assert.equal(stepControlsFor(r, old, [old]).retry, false)
+})
+
+test('no control is offered on a run that is over', () => {
+ for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
+ const parked = step({ status: 'running', parked: true })
+ assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), {
+ confirm: false,
+ skip: false,
+ retry: false,
+ })
+ }
+})
+
+test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => {
+ const steps = [
+ step({ id: 1, seq: 0, status: 'failed' }),
+ step({ id: 2, seq: 1, status: 'done' }),
+ step({ id: 3, seq: 2, status: 'pending' }),
+ step({ id: 4, seq: 0, phase: 'other', status: 'done' }),
+ ]
+ assert.equal(lastStartedSeqOf(steps, 'main'), 1)
+ assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null)
+ assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null)
+})
+
+// ── The definition form ────────────────────────────────────────────────────
+
+const ANNOUNCE = {
+ id: 'core.announce',
+ label: 'Announce',
+ risk: 'notify',
+ params: [
+ { name: 'leg', type: 'string', required: true, example: 'discord' },
+ { name: 'title', type: 'string', required: false, example: 'The gates open' },
+ { name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' },
+ ],
+}
+
+test('a new step arrives prefilled from the action’s declared examples', () => {
+ const fresh = blankStep(ANNOUNCE)
+ assert.equal(fresh.actionId, 'core.announce')
+ assert.deepEqual(JSON.parse(fresh.paramsText), {
+ leg: 'discord',
+ title: 'The gates open',
+ body: 'A caravan was sighted.',
+ })
+})
+
+test('a new phase never collides with an existing key', () => {
+ // Two phases sharing a key would silently collapse at materialisation —
+ // `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
+ // steps would never exist. The server refuses it; the form must not propose it.
+ const first = blankPhase([])
+ const second = blankPhase([first])
+ const third = blankPhase([first, second])
+ assert.equal(new Set([first.key, second.key, third.key]).size, 3)
+})
+
+test('the form round-trips a definition without losing a step', () => {
+ const event = {
+ title: 'Invasion',
+ graceSeconds: 600,
+ timezone: 'Europe/Berlin',
+ concurrencyKey: 'invasion:{region}',
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [
+ {
+ key: 'warn',
+ label: 'Warning',
+ steps: [
+ { actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
+ { actionId: 'core.wait', params: { seconds: 300 } },
+ ],
+ },
+ ],
+ },
+ }
+
+ const built = payloadFromForm(formFromDefinition(event))
+ assert.equal(built.ok, true)
+ assert.deepEqual(built.payload.spec.phases, [
+ {
+ key: 'warn',
+ label: 'Warning',
+ steps: [
+ { actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
+ { actionId: 'core.wait', params: { seconds: 300 } },
+ ],
+ },
+ ])
+ assert.equal(built.payload.graceSeconds, 600)
+ assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
+})
+
+test('an unchosen onFailure is omitted rather than invented', () => {
+ // The server defaults it from the action's risk class, which is the whole
+ // reason `risk` is required at registration. A form that posted a value would
+ // silently override that — turning a `change` action's `pause` into a `skip`
+ // and advancing a run over a half-changed world.
+ const form = formFromDefinition({
+ spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
+ })
+ const built = payloadFromForm(form)
+ assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
+})
+
+test('a params box that is not JSON is refused with the step named', () => {
+ const form = formFromDefinition({
+ spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
+ })
+ form.phases[0].steps[0].paramsText = '{ leg: discord }'
+
+ const built = payloadFromForm(form)
+ assert.equal(built.ok, false)
+ assert.match(built.errors[0], /Phase 1 "Main", step 1/)
+})
+
+test('an empty params box is an empty object, not an error', () => {
+ assert.deepEqual(parseParams('').params, {})
+ assert.deepEqual(parseParams(' ').params, {})
+ assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
+ assert.ok(parseParams('"leg"').error)
+})
+
+// ── Rendering what happened ────────────────────────────────────────────────
+
+test('a human transition reads differently from the runner’s own', () => {
+ // Both are `run.status` rows. `detail.control` is the only thing that separates
+ // "the runner paused this because a world write failed" from "somebody pressed
+ // pause", and the console has to tell them apart at a glance.
+ const byRunner = describeLogLine({
+ kind: 'run.status',
+ detail: { from: 'running', to: 'paused', because: 'core.spawn' },
+ })
+ const byPerson = describeLogLine({
+ kind: 'run.status',
+ detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
+ })
+
+ assert.match(byRunner, /Running → Paused/)
+ assert.match(byRunner, /core\.spawn/)
+ assert.match(byPerson, /pause/)
+ assert.match(byPerson, /by staff/)
+ assert.match(byPerson, /shard is lagging/)
+})
+
+test('the log lines a run produces all render as something', () => {
+ const lines = [
+ { kind: 'run.created', detail: { version: 3, rehearsal: true } },
+ { kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
+ { kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
+ { kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
+ { kind: 'phase.completed', phase: 'warn', detail: {} },
+ { kind: 'step.parked', detail: { action: 'core.cue' } },
+ { kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
+ { kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
+ { kind: 'note', detail: {} },
+ ]
+ for (const line of lines) {
+ const text = describeLogLine(line)
+ assert.equal(typeof text, 'string')
+ assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
+ assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
+ }
+})
+
+test('every run status has a word, and an unknown one falls through rather than blanking', () => {
+ for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
+ assert.ok(runStatusWord(s).length > 0)
+ }
+ assert.equal(runStatusWord('something-new'), 'something-new')
+})
diff --git a/server/routes.guards.json b/server/routes.guards.json
index c53a92f..f7e7657 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -518,6 +518,15 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/cancel",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log",
@@ -527,6 +536,51 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/pause",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/resume",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/series",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 1adec3c..388ae08 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -229,10 +229,34 @@
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/cancel"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/pause"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/resume"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/series"
diff --git a/server/src/model/events/eventRunControls.model.js b/server/src/model/events/eventRunControls.model.js
new file mode 100644
index 0000000..314bc1e
--- /dev/null
+++ b/server/src/model/events/eventRunControls.model.js
@@ -0,0 +1,296 @@
+// ── The live run controls ──────────────────────────────────────────────────
+//
+// EVENTS.md §I ("live controls that are honest"), §K and §L. Six of them: pause,
+// resume and cancel act on a run; confirm, skip and retry act on one step. They
+// arrive in Phase 3 because Phase 2 is what gave them something to act on — a
+// run that announces, waits and completes on its own is exactly the run that
+// needs no control, and a run that paused on a failed world write is the one
+// that does.
+//
+// **Two of §I's six run-level controls are deliberately not here.**
+// `advance` — force a phase forward — has no honest meaning yet: a phase today
+// advances when its steps go terminal, and the per-step skip already does that
+// one step at a time. Phase 5 is what gives a phase an `advance` CONDITION, and
+// that is the first moment "force it anyway" means something an operator could
+// predict. `cleanup` needs Phase 8's resource ledger; there is nothing to
+// revert, so cancel takes `{ reason }` and gains `cleanup` when there is
+// something for it to do. Both are absent rather than inert, which is the
+// posture Phase 1 set and Phase 2 kept.
+//
+// **Every control is guarded on the status it may act from, and the guard is a
+// WHERE clause rather than a read-then-write.** A run console rendered thirty
+// seconds ago describes a run that has since moved — the runner ticks every
+// fifteen — so a control that checked in JavaScript and then wrote would race
+// the tick it exists to interrupt. `transition()` and the four step statements
+// are all compare-and-set, and a `false` from one of them is reported as a 409
+// naming the status the run is actually in.
+//
+// **Who may press them is `admin` + `moderator` (§K, §N2), and it is the widest
+// gate in this feature on purpose.** Starting a run commits the deployment to
+// everything the definition contains, unattended — that wants the narrowest gate
+// there is. Stopping one is incident response at 2am, and it wants the widest.
+
+const runsDb = require('./eventRuns.db')
+const stepsDb = require('./eventRunSteps.db')
+const logDb = require('./eventRunLog.db')
+
+const MAX_REASON = 500
+
+const clean = (raw) => {
+ const text = typeof raw === 'string' ? raw.trim() : ''
+ return text ? text.slice(0, MAX_REASON) : null
+}
+
+const conflict = (message) => ({ ok: false, status: 409, errors: [message] })
+
+/** The run, or a 404 shaped the way every other model here shapes one. */
+async function loadRun(runId) {
+ const run = await runsDb.getById(runId)
+ return run || null
+}
+
+/**
+ * A step of THIS run, or null.
+ *
+ * Scoped to the run rather than fetched by id alone: the step id arrives from a
+ * URL under a run id, and a control that acted on a step belonging to a
+ * different run would be a real one — the console's step ids are not secret and
+ * the two paths would otherwise never be compared.
+ */
+async function loadStep(runId, stepId) {
+ const step = await stepsDb.getById(stepId)
+ if (!step || Number(step.run_id) !== Number(runId)) return null
+ return step
+}
+
+// ── Run-level ─────────────────────────────────────────────────────────────
+
+/**
+ * Pause a run in flight.
+ *
+ * `starting` and `running` only — §K's "live control of a run **in flight**". A
+ * `scheduled` run has not begun, and the thing to do with an occurrence that
+ * should not happen is cancel it: pausing one would leave a run that is neither
+ * going to start nor visibly abandoned, and resuming it after its grace window
+ * had passed would produce a `missed` from a button labelled resume.
+ *
+ * The claim is cleared with the transition. A tick may be working the run at
+ * this exact moment; it will find its guarded writes returning zero rows and
+ * hand back a lease it no longer holds, both of which are no-ops. What it will
+ * NOT do is dispatch the rest of its batch — `advanceRun` re-reads the status
+ * between steps precisely so this control means what it says.
+ */
+async function pause(runId, { reason } = {}, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ if (run.status === 'paused') return conflict('this run is already paused')
+
+ const note = clean(reason)
+ if (!(await runsDb.transition(run.id, ['starting', 'running'], 'paused', { clearClaim: true }))) {
+ return conflict(`a ${run.status} run cannot be paused`)
+ }
+
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ phase: run.current_phase,
+ detail: { from: run.status, to: 'paused', control: 'pause', by: userId, reason: note },
+ })
+ return { ok: true, run: await runsDb.getById(run.id) }
+}
+
+/**
+ * Resume a paused run.
+ *
+ * Where it goes back to is derived rather than remembered: `current_phase` is
+ * set by the transition into `running` and by nothing else, so a paused run that
+ * has one was running and a paused run that has none never got past `starting`.
+ * Both statuses are in `findDue`, so the next tick picks the run up either way,
+ * and there is no fourth column recording what a run was paused *from* — a
+ * column that could disagree with the run's own history.
+ *
+ * **`last_error` is cleared and `health` is not.** The error is what the pause
+ * was about and an operator has just dealt with it; leaving it on the banner
+ * would have a healthy run permanently accused of a failure that is in the log
+ * where it belongs. Health is a different claim — that this run has already had
+ * trouble — and it stays true no matter who pressed resume.
+ */
+async function resume(runId, options = {}, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ if (run.status !== 'paused') return conflict(`a ${run.status} run is not paused`)
+
+ const to = run.current_phase ? 'running' : 'starting'
+ if (!(await runsDb.transition(run.id, 'paused', to, { error: null }))) {
+ return conflict('this run stopped being paused')
+ }
+
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ phase: run.current_phase,
+ detail: { from: 'paused', to, control: 'resume', by: userId },
+ })
+ return { ok: true, run: await runsDb.getById(run.id) }
+}
+
+/**
+ * Cancel a run.
+ *
+ * Legal from every non-terminal status including `scheduled`, because "this
+ * event is not happening" is a decision an operator makes before it starts as
+ * often as during it.
+ *
+ * `cancelOpen` then closes out the steps that will never run — the pending ones
+ * and any parked cue. A step with a LIVE lease is left exactly where it is:
+ * something is dispatching it, nothing can recall a command already sent (§L),
+ * and a second writer on that row would race the process that owns it. It
+ * finishes into a cancelled run, which is honest.
+ */
+async function cancel(runId, { reason } = {}, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is already ${run.status}`)
+
+ const note = clean(reason)
+ const from = ['scheduled', 'starting', 'running', 'paused', 'ending']
+ if (!(await runsDb.transition(run.id, from, 'cancelled', { error: note || 'cancelled by staff' }))) {
+ return conflict('this run is no longer cancellable')
+ }
+
+ const closed = await stepsDb.cancelOpen(run.id)
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ phase: run.current_phase,
+ detail: { from: run.status, to: 'cancelled', control: 'cancel', by: userId, reason: note, cancelledSteps: closed },
+ })
+ return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
+}
+
+// ── Step-level ────────────────────────────────────────────────────────────
+
+/**
+ * Confirm a parked step — the GM cue's other half.
+ *
+ * `core.cue` posts an instruction and parks: the step stays `running` with a
+ * NULL lease, genuinely in flight with nothing holding it, so no sweep takes it
+ * back and a cue posted on Friday is still waiting on Monday. This is what ends
+ * it, and it is the control that makes the whole system useful before any module
+ * automates anything — a GM does the target-driven part in-client and says so
+ * here.
+ *
+ * The outcome is `done`, not `skipped`: a person saying they did the thing is
+ * the step having succeeded. The note is what they did, and it is kept.
+ */
+async function confirmStep(runId, stepId, { note } = {}, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ const step = await loadStep(runId, stepId)
+ if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
+
+ const text = clean(note)
+ if (!(await stepsDb.confirmParked(step.id, text))) {
+ return conflict(`this step is ${step.status} and is not waiting on anyone`)
+ }
+
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.status',
+ phase: step.phase,
+ detail: { to: 'done', action: step.action_id, control: 'confirm', by: userId, note: text },
+ })
+ return { ok: true, step: await stepsDb.getById(step.id) }
+}
+
+/**
+ * Skip a step: one that has not started, or a parked cue nobody is going to do.
+ *
+ * This is what the `skipped` status was reserved for (§L) — which is also why
+ * the three `on_failure` dispositions all write `failed` instead. A status
+ * meaning both "a human decided against this" and "this was attempted three
+ * times and never worked" would make the console's summary line unreadable.
+ *
+ * A `failed` step is not skippable and does not need to be: `nextOpenStep`
+ * already passes over one, so resuming a run carries the phase past it.
+ */
+async function skipStep(runId, stepId, { reason } = {}, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is ${run.status}`)
+ const step = await loadStep(runId, stepId)
+ if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
+
+ const note = clean(reason)
+ if (!(await stepsDb.skipByHuman(step.id, note))) {
+ return conflict(`a ${step.status} step cannot be skipped`)
+ }
+
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.status',
+ phase: step.phase,
+ detail: { to: 'skipped', action: step.action_id, control: 'skip', by: userId, reason: note },
+ })
+ return { ok: true, step: await stepsDb.getById(step.id) }
+}
+
+/**
+ * Re-queue the failed step a run is stopped at, and resume the run — one action.
+ *
+ * **The two halves are one control because there is no state in which you would
+ * want half of it.** Retry is legal only from `paused`, and a paused run is
+ * paused *at* this step; re-queueing without resuming would leave the run in
+ * precisely the state it was already in, with a button the operator now has to
+ * find. Splitting them would read as honesty and behave as a trap.
+ *
+ * Two guards, and the second is the one worth explaining. The step must be the
+ * furthest one its phase has reached — `lastStartedSeq` — because a `failed`
+ * step under an `on_failure` of `skip` is one the run has already moved PAST.
+ * `nextOpenStep` selects `pending` and `running` only, so the runner steps over
+ * a failed row; re-queueing an earlier one puts a `pending` step behind the
+ * cursor, where it sits for ever.
+ */
+async function retryStep(runId, stepId, options = {}, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ if (run.status !== 'paused') {
+ return conflict(`a step can only be retried while its run is paused; this run is ${run.status}`)
+ }
+ const step = await loadStep(runId, stepId)
+ if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
+ if (step.status !== 'failed') return conflict(`a ${step.status} step cannot be retried`)
+ if (step.phase !== run.current_phase) {
+ return conflict('this step belongs to a phase the run has already left')
+ }
+
+ const furthest = await stepsDb.lastStartedSeq(run.id, step.phase)
+ if (furthest === null || Number(furthest) !== Number(step.seq)) {
+ return conflict('the run is not stopped at this step; only the step a phase is stopped at can be retried')
+ }
+
+ if (!(await stepsDb.requeue(step.id))) return conflict('this step is no longer failed')
+
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.status',
+ phase: step.phase,
+ detail: { to: 'pending', action: step.action_id, control: 'retry', by: userId, attemptsReset: step.attempts },
+ })
+
+ const resumed = await resume(runId, {}, userId)
+ return {
+ ok: true,
+ step: await stepsDb.getById(step.id),
+ // A resume that did not take is reported rather than swallowed: the step IS
+ // re-queued either way, and an operator told "retried" about a run that is
+ // still paused would be told something false.
+ resumed: Boolean(resumed.ok),
+ run: resumed.run || (await runsDb.getById(run.id)),
+ }
+}
+
+module.exports = { pause, resume, cancel, confirmStep, skipStep, retryStep }
diff --git a/server/src/model/events/eventRunSteps.db.js b/server/src/model/events/eventRunSteps.db.js
index d9d4041..43be47b 100644
--- a/server/src/model/events/eventRunSteps.db.js
+++ b/server/src/model/events/eventRunSteps.db.js
@@ -274,6 +274,130 @@ const cancelPending = async (runId) => {
return Number(result?.affectedRows || 0)
}
+// ── Phase 3: the controls a human works ────────────────────────────────────
+//
+// Four statements, and every one of them is guarded on the status it is allowed
+// to act from rather than trusting the button that was pressed. The run console
+// decides what to OFFER; these decide what may happen, and they disagree on
+// purpose — a console rendered thirty seconds ago is a console describing a run
+// that has since moved.
+//
+// **A parked step is `running` with a NULL lease**, and that pair is the whole
+// vocabulary these need. `park()` above is the only thing that produces it, so
+// `status = 'running' AND claim_expires_at IS NULL` names a cue waiting on a
+// human and cannot name a step some process is mid-dispatch on. Confirm and skip
+// are both written against it, which is what makes them safe to expose to a
+// moderator: neither can touch a step the runner is holding.
+
+/**
+ * The highest `seq` of a step in this phase that is not still `pending` — the
+ * furthest the phase has got — or null if none of it has been attempted.
+ *
+ * It exists for the retry control, and the definition is chosen to agree with
+ * the runner's own cursor rather than to look tidy. Steps within a phase are
+ * strictly serial, so the last step that is not pending is the last one the
+ * runner worked on; if the run is `paused` that step is what it paused at.
+ *
+ * **The near miss worth recording: "the lowest step that is not settled" is the
+ * wrong rule**, and it looks right. `nextOpenStep` selects `pending` and
+ * `running` only, so a `failed` step is one the runner has already stepped OVER
+ * — which is exactly what an `on_failure` of `skip` produces. Under that rule a
+ * phase whose second step failed-and-skipped and whose fifth then failed-and-
+ * paused would offer retry on the second, re-queueing a row behind the runner's
+ * cursor where it would sit pending for ever.
+ */
+const lastStartedSeq = async (runId, phase) => {
+ const [row] = await query(
+ `SELECT MAX(seq) AS seq FROM event_run_steps
+ WHERE run_id = ? AND phase = ? AND status <> 'pending'`,
+ [runId, phase],
+ )
+ return row?.seq === null || row?.seq === undefined ? null : Number(row.seq)
+}
+
+/**
+ * Resolve a parked step: the GM cue's confirm.
+ *
+ * `done` rather than `skipped` — a human saying they did the thing is the step
+ * having succeeded, and it is the only outcome under which the instruction was
+ * actually carried out. The note is kept in `last_error` for the same reason the
+ * park's is: it is the column the console already renders beside the step, and a
+ * second one for prose would be a column two writers disagree about.
+ */
+const confirmParked = async (id, note) => {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET status = 'done', finished_at = NOW(), claimed_by = NULL,
+ last_error = ?
+ WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`,
+ [note ? String(note).slice(0, 500) : null, id],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Skip a step a human has decided not to run: `pending`, or a parked cue.
+ *
+ * This is what `skipped` was reserved for (§L). A `running` step with a live
+ * lease is excluded — nothing can recall a command already sent — and a `failed`
+ * one is excluded because it is already terminal and the run's own resume is
+ * what carries the phase past it.
+ */
+const skipByHuman = async (id, reason) => {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
+ last_error = ?
+ WHERE id = ?
+ AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
+ [reason ? String(reason).slice(0, 500) : null, id],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Put a failed step back in the queue for another attempt.
+ *
+ * **`attempts` goes back to zero, and that is not the rule Engagement Phase 14
+ * arrived at being broken.** That rule is about SWEEPS: an automatic path that
+ * reset a counter made the ceiling unreachable and the row immortal. This is a
+ * named person deciding, once, that the thing which failed three times will work
+ * now — `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does unattended, and a
+ * human is the thing it is unattended from. The decision is in the run log with
+ * the actor on it.
+ */
+const requeue = async (id) => {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
+ claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
+ WHERE id = ? AND status = 'failed'`,
+ [id],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/**
+ * Close out every step a cancelled run will never run: pending, and parked.
+ *
+ * Wider than `cancelPending` by exactly one case, and deliberately so. §L leaves
+ * a `running` step alone because nothing can recall a sent command — but a
+ * parked cue is not a sent command, it is an instruction nobody is holding, and
+ * leaving it `running` after the run was cancelled would leave the console
+ * claiming a cancelled event is still waiting for someone. The live lease is
+ * what distinguishes them, and it is in the WHERE clause.
+ */
+const cancelOpen = async (runId) => {
+ const result = await query(
+ `UPDATE event_run_steps
+ SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
+ WHERE run_id = ?
+ AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
+ [runId],
+ )
+ return Number(result?.affectedRows || 0)
+}
+
module.exports = {
listForRun,
listForPhase,
@@ -289,4 +413,9 @@ module.exports = {
holdNext,
reclaimStale,
cancelPending,
+ lastStartedSeq,
+ confirmParked,
+ skipByHuman,
+ requeue,
+ cancelOpen,
}
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index a9c06fe..3c976f3 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -23,8 +23,17 @@ const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), r
// only if every path a run can take reaches one of them.
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
+// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
+// pair `park()` alone produces, which means a cue waiting on a human. It is a
+// correlated subquery on an admin list bounded at 500 rows rather than a column,
+// because it is derived from the steps and a column would be a second writer's
+// opinion of them. It earns its cost on the list screen: a cue nobody notices is
+// a run that never advances, and the run itself looks perfectly healthy until
+// somebody opens it.
const SELECT_LIST = `
- SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number
+ SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number,
+ (SELECT COUNT(*) FROM event_run_steps s
+ WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
JOIN event_versions v ON v.id = r.version_id
@@ -241,6 +250,20 @@ async function transition(id, from, to, { phase, error, clearClaim = false } = {
return Number(result?.affectedRows || 0) === 1
}
+/**
+ * Just this run's status, for a caller that must not act on a stale read.
+ *
+ * The runner drains a bounded batch of steps from one run inside a single tick,
+ * and Phase 3 put a pause and a cancel button in a human's hand — so between two
+ * steps of that batch the run may have stopped. A loop that only re-checked at
+ * the top of the tick would answer a pause by dispatching another two dozen
+ * steps, which is not a pause. One column, by primary key.
+ */
+const statusOf = async (id) => {
+ const [row] = await query('SELECT status FROM event_runs WHERE id = ?', [id])
+ return row?.status || null
+}
+
/**
* Set health without touching status (§E).
*
@@ -354,6 +377,7 @@ module.exports = {
claimStart,
claimTick,
releaseClaim,
+ statusOf,
transition,
setHealth,
concurrencyHolder,
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 7080dee..a9b0900 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -8,10 +8,14 @@
// a definition arriving from a future import or a restore gets the same answer
// this screen does.
//
-// **What is deliberately absent**: pause, resume, advance, cancel, step
-// skip/retry/confirm, cleanup and the action switchboard. Each of them acts on a
-// run in flight, and nothing is in flight until Phase 2 builds the runner. A
-// control that returns 200 and does nothing is worse than one that is not there.
+// **Phase 3 added the live run controls** at the bottom of this file: pause,
+// resume, cancel, and a step's confirm, skip and retry. What is still absent is
+// `advance`, `cleanup` and the action switchboard — `advance` has no honest
+// meaning until Phase 5 gives a phase an advance condition, `cleanup` has no
+// ledger to work over until Phase 8, and the switchboard is Phase 6's. Each of
+// them is absent rather than stubbed, for the reason the whole set was in Phase
+// 1: a control that returns 200 and does nothing is worse than one that is not
+// there.
const registries = require('../../../modules/registries')
const spec = require('../../../events/spec')
@@ -21,6 +25,7 @@ const versionsDb = require('../../../model/events/eventVersions.db')
const seriesDb = require('../../../model/events/eventSeries.db')
const runsDb = require('../../../model/events/eventRuns.db')
const runs = require('../../../model/events/eventRuns.model')
+const controls = require('../../../model/events/eventRunControls.model')
const logDb = require('../../../model/events/eventRunLog.db')
const activity = require('../../../model/activity/activity.model')
@@ -80,6 +85,10 @@ const shapeRun = (r) => ({
endedAt: r.ended_at,
lastError: r.last_error,
createdAt: r.created_at,
+ // How many steps are parked on a human. Derived, not a column, and surfaced on
+ // the LIST as well as the console because a cue nobody notices is a run that
+ // never advances while looking perfectly healthy from the outside.
+ waitingSteps: Number(r.waiting_steps || 0),
})
const shapeStep = (s) => ({
@@ -91,6 +100,12 @@ const shapeStep = (s) => ({
params: s.params,
actionVersion: s.action_version,
status: s.status,
+ // `running` with no lease is a parked step (§E) — waiting on a human, with
+ // nothing holding it. The console has to tell that apart from a step some
+ // process is mid-dispatch on, and it must not do so by being shown the lease:
+ // one derived boolean rather than `claimed_by` and `claim_expires_at`, which
+ // are the runner's business and would invite a UI that reasoned about leases.
+ parked: s.status === 'running' && !s.claim_expires_at,
dueAt: s.due_at,
attempts: s.attempts,
onFailure: s.on_failure,
@@ -313,3 +328,89 @@ exports.startRun = async (req, res) => {
created: result.created,
})
}
+
+// ── Phase 3: the live run controls ─────────────────────────────────────────
+//
+// Six handlers, and each is the same four lines: read the ids out of the URL,
+// hand off to `eventRunControls`, log the manual transition to `activity_log`,
+// answer with the row. Every guard is in the model, where a control invoked from
+// anywhere else gets the same answer — which is the same division this file has
+// had since Phase 1.
+//
+// **The audit is written in two places on purpose, and they are not redundant.**
+// `event_run_log` is the run's own diagnostic record: queryable by phase and by
+// step, and it is what the console renders. `activity_log` is the deployment's
+// record of what staff did, and it is where "who cancelled the invasion" is
+// looked up months later by somebody who is not looking at that run. §J names
+// both.
+
+/** POST /api/v1/admin/events/runs/:runId/pause */
+exports.pauseRun = async (req, res) => {
+ const runId = asId(req.params.runId)
+ if (!runId) return res.status(400).json({ error: 'bad run id' })
+ const result = await controls.pause(runId, { reason: req.body?.reason }, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.run.paused', detail: { runId, reason: req.body?.reason || null } })
+ return res.json({ run: shapeRun(result.run) })
+}
+
+/** POST /api/v1/admin/events/runs/:runId/resume */
+exports.resumeRun = async (req, res) => {
+ const runId = asId(req.params.runId)
+ if (!runId) return res.status(400).json({ error: 'bad run id' })
+ const result = await controls.resume(runId, {}, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.run.resumed', detail: { runId } })
+ return res.json({ run: shapeRun(result.run) })
+}
+
+/** POST /api/v1/admin/events/runs/:runId/cancel */
+exports.cancelRun = async (req, res) => {
+ const runId = asId(req.params.runId)
+ if (!runId) return res.status(400).json({ error: 'bad run id' })
+ const result = await controls.cancel(runId, { reason: req.body?.reason }, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({
+ req,
+ action: 'event.run.cancelled',
+ detail: { runId, reason: req.body?.reason || null, cancelledSteps: result.cancelledSteps },
+ })
+ return res.json({ run: shapeRun(result.run), cancelledSteps: result.cancelledSteps })
+}
+
+/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/confirm */
+exports.confirmStep = async (req, res) => {
+ const runId = asId(req.params.runId)
+ const stepId = asId(req.params.stepId)
+ if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
+ const result = await controls.confirmStep(runId, stepId, { note: req.body?.note }, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.step.confirmed', detail: { runId, stepId, action: result.step.action_id } })
+ return res.json({ step: shapeStep(result.step) })
+}
+
+/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/skip */
+exports.skipStep = async (req, res) => {
+ const runId = asId(req.params.runId)
+ const stepId = asId(req.params.stepId)
+ if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
+ const result = await controls.skipStep(runId, stepId, { reason: req.body?.reason }, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({
+ req,
+ action: 'event.step.skipped',
+ detail: { runId, stepId, action: result.step.action_id, reason: req.body?.reason || null },
+ })
+ return res.json({ step: shapeStep(result.step) })
+}
+
+/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/retry */
+exports.retryStep = async (req, res) => {
+ const runId = asId(req.params.runId)
+ const stepId = asId(req.params.stepId)
+ if (!runId || !stepId) return res.status(400).json({ error: 'bad run or step id' })
+ const result = await controls.retryStep(runId, stepId, {}, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.step.retried', detail: { runId, stepId, action: result.step.action_id } })
+ return res.json({ step: shapeStep(result.step), run: shapeRun(result.run), resumed: result.resumed })
+}
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 36c28b6..9c1360b 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -12,9 +12,11 @@
// are here anyway, because a button that is admin-only later and open now is a
// gate nobody notices was missing.
//
-// Reads are staff-wide. `verify` (admin, editor) is Phase 6's, and the live run
-// controls (admin, moderator) are Phase 3's — neither is stubbed here, because
-// nothing is in flight until Phase 2 builds the runner.
+// Reads are staff-wide. The live run controls landed in Phase 3 and are `admin`
+// + `moderator`, deliberately wider than start (§N2). `verify` (admin, editor),
+// `advance`, `cleanup` and the action switchboard are still absent rather than
+// stubbed — there is no advance condition until Phase 5, no resource ledger
+// until Phase 8 and no caps to price against until Phase 6.
//
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
// `/runs` are never read as an event id.
@@ -27,6 +29,10 @@ const { requireRole } = require('../../../utils/auth')
const eventsRouter = express.Router()
const adminOnly = requireRole('admin')
const adminOrEditor = requireRole('admin', 'editor')
+// Live control of a run in flight, and the one gate wider than `admin` in this
+// feature (§K). Named rather than inlined so the six routes below cannot drift
+// apart from one another.
+const liveControl = requireRole('admin', 'moderator')
// ── The catalog and the vocabularies, served from the registries ───────────
@@ -93,6 +99,105 @@ eventsRouter.get(
controller.getRunLog,
)
+// ── The live run controls (Phase 3) ───────────────────────────────────────
+//
+// `admin` + `moderator`, and it is the widest gate in this feature deliberately
+// (§K, §N2). Starting a run commits the deployment to everything the definition
+// contains, unattended, up to every cap it declares — that wants the narrowest
+// gate there is. Stopping one is incident response, and the incident is "the
+// event is doing something wrong at 2am" — that wants the widest. A split that
+// read consistent, with one role owning both buttons, would behave badly in
+// exactly the case the moderator role exists for.
+//
+// `advance` and `cleanup` from the § API surface table are not here: the first
+// has no honest meaning until Phase 5 gives a phase an advance condition, the
+// second has no resource ledger to work over until Phase 8.
+
+eventsRouter.post(
+ '/runs/:runId/pause',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Pause a run in flight'
+ // #swagger.description = 'A paused run is excluded from the runner\'s sweep and nothing advances it until resume. Legal from `starting` and `running` only — a `scheduled` occurrence that should not happen is cancelled, not paused, because resuming one after its grace window had passed would produce a `missed` from a button labelled resume. Takes effect at once even mid-tick: the runner re-reads the run\'s status between steps.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Recorded in the run log with the actor" } } } } } } */
+ /* #swagger.responses[200] = { description: 'The paused run', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[409] = { description: 'The run is not in flight', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ liveControl,
+ controller.pauseRun,
+)
+
+eventsRouter.post(
+ '/runs/:runId/resume',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Resume a paused run'
+ // #swagger.description = 'Where the run goes back to is derived rather than remembered: a paused run with a `current_phase` was running, one without never got past `starting`. `last_error` is cleared — the operator has just dealt with it — and `health` is not, because "this run has already had trouble" stays true whoever pressed resume.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The resumed run', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[409] = { description: 'The run is not paused', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ liveControl,
+ controller.resumeRun,
+)
+
+eventsRouter.post(
+ '/runs/:runId/cancel',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Cancel a run'
+ // #swagger.description = 'Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Why. Recorded on the run and in its log, with the actor." } } } } } } */
+ /* #swagger.responses[200] = { description: 'The cancelled run and how many steps were closed out with it', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, cancelledSteps: { type: "integer" } } } } } } */
+ /* #swagger.responses[409] = { description: 'The run has already reached a terminal status', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ liveControl,
+ controller.cancelRun,
+)
+
+eventsRouter.post(
+ '/runs/:runId/steps/:stepId/confirm',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Confirm a parked step — the GM cue'
+ // #swagger.description = 'The other half of `core.cue`. The action posts an instruction and parks the step `running` with a NULL lease — genuinely in flight, nothing holding it, so no sweep takes it back and a cue posted on Friday is still waiting on Monday. This ends it, as `done` rather than `skipped`: a person saying they did the thing is the step having succeeded. The optional note is what they did, and it is kept on the step and in the log.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { note: { type: "string", description: "What was actually done in-client" } } } } } } */
+ /* #swagger.responses[200] = { description: 'The confirmed step', content: { "application/json": { schema: { type: "object", properties: { step: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such run, or no such step on it', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[409] = { description: 'The step is not waiting on anyone', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ liveControl,
+ controller.confirmStep,
+)
+
+eventsRouter.post(
+ '/runs/:runId/steps/:stepId/skip',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Skip a step nobody is going to run'
+ // #swagger.description = 'A step that has not started, or a parked cue. This is what the `skipped` status was reserved for, and why all three `on_failure` dispositions write `failed` instead — a status meaning both "a human decided against this" and "this was attempted three times and never worked" would make the console summary unreadable. A step with a live lease cannot be skipped; a failed one does not need to be, because resuming the run already carries the phase past it.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string" } } } } } } */
+ /* #swagger.responses[200] = { description: 'The skipped step', content: { "application/json": { schema: { type: "object", properties: { step: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such run, or no such step on it', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[409] = { description: 'The step or its run is in a status that cannot be skipped', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ liveControl,
+ controller.skipStep,
+)
+
+eventsRouter.post(
+ '/runs/:runId/steps/:stepId/retry',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Re-queue the failed step a paused run is stopped at, and resume it'
+ // #swagger.description = 'One action rather than two, because there is no state in which you would want half of it: retry is legal only while the run is paused, and a paused run is paused AT this step. The step must be the one its phase is stopped at — a failed step under an `on_failure` of `skip` is one the run has already moved past, and re-queueing that would put a pending row behind the runner\'s cursor. `attempts` returns to zero: the attempt ceiling bounds what the runner does unattended, and a named person deciding is the thing it is unattended from.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The re-queued step and the run, with whether the resume took', content: { "application/json": { schema: { type: "object", properties: { step: { type: "object", additionalProperties: true }, run: { type: "object", additionalProperties: true }, resumed: { type: "boolean" } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such run, or no such step on it', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[409] = { description: 'The run is not paused, or the run is not stopped at this step', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ liveControl,
+ controller.retryStep,
+)
+
// ── Definitions ───────────────────────────────────────────────────────────
eventsRouter.get(
diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js
index e645496..3c7bee7 100644
--- a/server/src/utils/eventRunner.js
+++ b/server/src/utils/eventRunner.js
@@ -292,6 +292,14 @@ async function advanceRun(run, now) {
const carry = {}
for (let n = 0; n < STEPS_PER_TICK; n++) {
+ // Re-read the run's status between steps, not just at the top of the tick.
+ // This loop drains up to STEPS_PER_TICK steps from one run, and Phase 3 put
+ // a pause and a cancel in a human's hand: without this, pausing a run in the
+ // middle of a batch would answer by dispatching another two dozen steps,
+ // which is not a pause. One indexed column read per step, against a control
+ // whose entire value is that it takes effect at once.
+ if (n > 0 && (await runsDb.statusOf(run.id)) !== 'running') return 'stopped'
+
const phaseIndex = phases.findIndex((p) => p.key === phaseKey)
if (phaseIndex < 0) {
await runsDb.transition(run.id, ['running'], 'failed', { error: `phase "${phaseKey}" is not in the pinned version` })
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 608068f..8d7a039 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3766,6 +3766,101 @@
]
}
},
+ "/api/v1/admin/events/runs/{runId}/cancel": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Cancel a run",
+ "description": "Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The cancelled run and how many steps were closed out with it",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "cancelledSteps": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin or moderator",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The run has already reached a terminal status",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "type": "string",
+ "description": "Why. Recorded on the run and in its log, with the actor."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/events/runs/{runId}/log": {
"get": {
"tags": [
@@ -3842,6 +3937,494 @@
]
}
},
+ "/api/v1/admin/events/runs/{runId}/pause": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Pause a run in flight",
+ "description": "A paused run is excluded from the runner\\'s sweep and nothing advances it until resume. Legal from `starting` and `running` only — a `scheduled` occurrence that should not happen is cancelled, not paused, because resuming one after its grace window had passed would produce a `missed` from a button labelled resume. Takes effect at once even mid-tick: the runner re-reads the run\\'s status between steps.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The paused run",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin or moderator",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The run is not in flight",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "type": "string",
+ "description": "Recorded in the run log with the actor"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/events/runs/{runId}/resume": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Resume a paused run",
+ "description": "Where the run goes back to is derived rather than remembered: a paused run with a `current_phase` was running, one without never got past `starting`. `last_error` is cleared — the operator has just dealt with it — and `health` is not, because \"this run has already had trouble\" stays true whoever pressed resume.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The resumed run",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin or moderator",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The run is not paused",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/runs/{runId}/steps/{stepId}/confirm": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Confirm a parked step — the GM cue",
+ "description": "The other half of `core.cue`. The action posts an instruction and parks the step `running` with a NULL lease — genuinely in flight, nothing holding it, so no sweep takes it back and a cue posted on Friday is still waiting on Monday. This ends it, as `done` rather than `skipped`: a person saying they did the thing is the step having succeeded. The optional note is what they did, and it is kept on the step and in the log.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "stepId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The confirmed step",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "step": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin or moderator",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such run, or no such step on it",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The step is not waiting on anyone",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "note": {
+ "type": "string",
+ "description": "What was actually done in-client"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/events/runs/{runId}/steps/{stepId}/retry": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Re-queue the failed step a paused run is stopped at, and resume it",
+ "description": "One action rather than two, because there is no state in which you would want half of it: retry is legal only while the run is paused, and a paused run is paused AT this step. The step must be the one its phase is stopped at — a failed step under an `on_failure` of `skip` is one the run has already moved past, and re-queueing that would put a pending row behind the runner\\'s cursor. `attempts` returns to zero: the attempt ceiling bounds what the runner does unattended, and a named person deciding is the thing it is unattended from.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "stepId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The re-queued step and the run, with whether the resume took",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "step": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "resumed": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin or moderator",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such run, or no such step on it",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The run is not paused, or the run is not stopped at this step",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/events/runs/{runId}/steps/{stepId}/skip": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Skip a step nobody is going to run",
+ "description": "A step that has not started, or a parked cue. This is what the `skipped` status was reserved for, and why all three `on_failure` dispositions write `failed` instead — a status meaning both \"a human decided against this\" and \"this was attempted three times and never worked\" would make the console summary unreadable. A step with a live lease cannot be skipped; a failed one does not need to be, because resuming the run already carries the phase past it.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "stepId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The skipped step",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "step": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin or moderator",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such run, or no such step on it",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The step or its run is in a status that cannot be skipped",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/events/series": {
"get": {
"tags": [
diff --git a/server/test/eventRunControls.test.js b/server/test/eventRunControls.test.js
new file mode 100644
index 0000000..3c71a93
--- /dev/null
+++ b/server/test/eventRunControls.test.js
@@ -0,0 +1,442 @@
+// ── The live run controls (EVENTS_PLAN.md Phase 3) ─────────────────────────
+//
+// Six controls, and what is tested is almost entirely the REFUSALS. A control
+// that works is easy; a control that works from a status it should not have
+// worked from is a staff member changing a live game world by pressing a button
+// a stale screen offered them. So each of the six is exercised from every status
+// it must decline, and the four that a run console could plausibly offer wrongly
+// get a test of their own:
+//
+// • retry on a step the run has already moved past (the `skip` disposition) —
+// the test that found the first draft's guard was reading the wrong end of
+// the phase
+// • confirm on a step a process is mid-dispatch on, not a parked cue
+// • skip on a step with a live lease
+// • cancel closing out a parked cue, so a cancelled run stops "waiting"
+//
+// The three tables are stubbed at the `.db` layer and the model's own logic runs
+// for real against them — the shape `eventRunner.test.js` uses. What a stub
+// cannot prove is that the five statements mean this against a real server; the
+// guards that are pure SQL (`status = 'running' AND claim_expires_at IS NULL`
+// and `lastStartedSeq`'s MAX) are proved in `eventRunnerSql.test.js`.
+//
+// Point the DB at a closed port before requiring anything.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const controls = require('../src/model/events/eventRunControls.model')
+const runsDb = require('../src/model/events/eventRuns.db')
+const stepsDb = require('../src/model/events/eventRunSteps.db')
+const logDb = require('../src/model/events/eventRunLog.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
+const ACTOR = 7
+
+let store
+const originals = [
+ ['runs', runsDb, { ...runsDb }],
+ ['steps', stepsDb, { ...stepsDb }],
+ ['log', logDb, { ...logDb }],
+]
+
+function installStubs() {
+ store = { runs: new Map(), steps: new Map(), log: [], nextStepId: 1 }
+ const snap = (o) => ({ ...o })
+
+ runsDb.getById = async (id) => {
+ const r = store.runs.get(Number(id))
+ return r ? snap(r) : null
+ }
+
+ runsDb.transition = async (id, from, to, opts = {}) => {
+ const r = store.runs.get(Number(id))
+ const froms = Array.isArray(from) ? from : [from]
+ if (!r || !froms.includes(r.status)) return false
+ r.status = to
+ if (opts.phase !== undefined) r.current_phase = opts.phase
+ if (opts.error !== undefined) r.last_error = opts.error
+ if (TERMINAL.includes(to) || opts.clearClaim) {
+ r.claimed_by = null
+ r.claim_expires_at = null
+ }
+ return true
+ }
+
+ stepsDb.getById = async (id) => {
+ const s = store.steps.get(Number(id))
+ return s ? snap(s) : null
+ }
+
+ // Each of the four mirrors its statement's WHERE clause exactly. A stub can
+ // only ever agree with whoever wrote it, so what these buy is the model's
+ // logic around them; the clauses themselves are checked against a real server.
+ stepsDb.confirmParked = async (id, note) => {
+ const s = store.steps.get(Number(id))
+ if (!s || s.status !== 'running' || s.claim_expires_at) return false
+ Object.assign(s, { status: 'done', last_error: note, claimed_by: null })
+ return true
+ }
+
+ stepsDb.skipByHuman = async (id, reason) => {
+ const s = store.steps.get(Number(id))
+ if (!s) return false
+ const ok = s.status === 'pending' || (s.status === 'running' && !s.claim_expires_at)
+ if (!ok) return false
+ Object.assign(s, { status: 'skipped', last_error: reason, claimed_by: null })
+ return true
+ }
+
+ stepsDb.requeue = async (id) => {
+ const s = store.steps.get(Number(id))
+ if (!s || s.status !== 'failed') return false
+ Object.assign(s, { status: 'pending', attempts: 0, due_at: null, last_error: null, claimed_by: null, claim_expires_at: null })
+ return true
+ }
+
+ stepsDb.cancelOpen = async (runId) => {
+ let n = 0
+ for (const s of store.steps.values()) {
+ if (s.run_id !== Number(runId)) continue
+ if (s.status === 'pending' || (s.status === 'running' && !s.claim_expires_at)) {
+ s.status = 'cancelled'
+ n += 1
+ }
+ }
+ return n
+ }
+
+ stepsDb.lastStartedSeq = async (runId, phase) => {
+ const started = [...store.steps.values()]
+ .filter((s) => s.run_id === Number(runId) && s.phase === phase && s.status !== 'pending')
+ .map((s) => s.seq)
+ return started.length ? Math.max(...started) : null
+ }
+
+ logDb.write = async (line) => {
+ store.log.push(line)
+ return true
+ }
+}
+
+beforeEach(installStubs)
+afterEach(() => {
+ for (const [, mod, fns] of originals) Object.assign(mod, fns)
+})
+
+let nextRunId = 1
+
+function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
+ const id = nextRunId++
+ store.runs.set(id, {
+ id,
+ definition_id: id,
+ version_id: id,
+ status,
+ health: 'ok',
+ current_phase: phase,
+ claimed_by: null,
+ claim_expires_at: null,
+ last_error: null,
+ })
+ steps.forEach((s, i) => {
+ const stepId = store.nextStepId++
+ store.steps.set(stepId, {
+ id: stepId,
+ run_id: id,
+ phase: s.phase || phase,
+ seq: s.seq ?? i,
+ action_id: s.actionId || 'test.action',
+ status: s.status || 'pending',
+ attempts: s.attempts ?? 0,
+ due_at: null,
+ claimed_by: s.leased ? 'someone' : null,
+ claim_expires_at: s.leased ? new Date(Date.now() + 60_000) : null,
+ last_error: null,
+ params: {},
+ })
+ })
+ return id
+}
+
+const runRow = (id) => store.runs.get(id)
+const stepsOf = (id) => [...store.steps.values()].filter((s) => s.run_id === id).sort((a, b) => a.seq - b.seq)
+const lastLog = () => store.log[store.log.length - 1]
+
+// ── pause / resume ─────────────────────────────────────────────────────────
+
+test('pause takes a run in flight and records who did it', async () => {
+ const id = seedRun({ status: 'running' })
+ const result = await controls.pause(id, { reason: 'the shard is lagging' }, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.equal(runRow(id).status, 'paused')
+ assert.deepEqual(lastLog().detail, {
+ from: 'running',
+ to: 'paused',
+ control: 'pause',
+ by: ACTOR,
+ reason: 'the shard is lagging',
+ })
+})
+
+test('pause drops the claim, so the next tick is not locked out of a resumed run', async () => {
+ const id = seedRun({ status: 'running' })
+ Object.assign(runRow(id), { claimed_by: 'host:1', claim_expires_at: new Date(Date.now() + 900_000) })
+
+ await controls.pause(id, {}, ACTOR)
+
+ assert.equal(runRow(id).claimed_by, null)
+ assert.equal(runRow(id).claim_expires_at, null)
+})
+
+test('a scheduled run cannot be paused — it is cancelled instead', async () => {
+ // Pausing one would leave a run that is neither going to start nor visibly
+ // abandoned, and resuming it after its grace window had passed would produce a
+ // `missed` from a button labelled resume.
+ const id = seedRun({ status: 'scheduled' })
+ const result = await controls.pause(id, {}, ACTOR)
+
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 409)
+ assert.match(result.errors[0], /scheduled/)
+ assert.equal(runRow(id).status, 'scheduled')
+})
+
+test('a completed run cannot be paused', async () => {
+ const id = seedRun({ status: 'completed' })
+ assert.equal((await controls.pause(id, {}, ACTOR)).ok, false)
+})
+
+test('resume returns a run to running, or to starting when it never entered a phase', async () => {
+ const withPhase = seedRun({ status: 'paused', phase: 'main' })
+ assert.equal((await controls.resume(withPhase, {}, ACTOR)).ok, true)
+ assert.equal(runRow(withPhase).status, 'running')
+
+ const beforePhase = seedRun({ status: 'paused', phase: null })
+ assert.equal((await controls.resume(beforePhase, {}, ACTOR)).ok, true)
+ assert.equal(runRow(beforePhase).status, 'starting', 'both are in findDue; neither is a fourth column')
+})
+
+test('resume clears the error it was paused over and leaves health alone', async () => {
+ const id = seedRun({ status: 'paused' })
+ Object.assign(runRow(id), { last_error: 'core.spawn failed', health: 'degraded' })
+
+ await controls.resume(id, {}, ACTOR)
+
+ assert.equal(runRow(id).last_error, null, 'a resolved failure must not accuse a healthy run for ever')
+ assert.equal(runRow(id).health, 'degraded', 'that this run has already had trouble stays true')
+})
+
+test('resume refuses a run that is not paused', async () => {
+ const id = seedRun({ status: 'running' })
+ const result = await controls.resume(id, {}, ACTOR)
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 409)
+})
+
+// ── cancel ─────────────────────────────────────────────────────────────────
+
+test('cancel closes out the pending steps and the parked cue, and leaves a leased step alone', async () => {
+ const id = seedRun({
+ status: 'running',
+ steps: [
+ { status: 'done' },
+ { status: 'running', leased: true }, // mid-dispatch: nothing can recall a sent command
+ { status: 'running' }, // parked on a human: nothing is holding it
+ { status: 'pending' },
+ ],
+ })
+
+ const result = await controls.cancel(id, { reason: 'called off' }, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.equal(runRow(id).status, 'cancelled')
+ assert.equal(result.cancelledSteps, 2)
+ const [done, leased, parked, pending] = stepsOf(id)
+ assert.equal(done.status, 'done')
+ assert.equal(leased.status, 'running', 'a step being dispatched is not touched')
+ assert.equal(parked.status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
+ assert.equal(pending.status, 'cancelled')
+})
+
+test('cancel is legal before a run has started', async () => {
+ const id = seedRun({ status: 'scheduled', steps: [{ status: 'pending' }] })
+ assert.equal((await controls.cancel(id, {}, ACTOR)).ok, true)
+ assert.equal(runRow(id).status, 'cancelled')
+})
+
+test('cancel refuses a run that is already terminal', async () => {
+ for (const status of TERMINAL) {
+ const id = seedRun({ status })
+ const result = await controls.cancel(id, {}, ACTOR)
+ assert.equal(result.ok, false, `${status} should not be cancellable`)
+ assert.match(result.errors[0], new RegExp(status))
+ }
+})
+
+// ── confirm ────────────────────────────────────────────────────────────────
+
+test('confirm resolves a parked cue as done, keeping what the person says they did', async () => {
+ const id = seedRun({ status: 'running', steps: [{ status: 'running', actionId: 'core.cue' }] })
+ const [cue] = stepsOf(id)
+
+ const result = await controls.confirmStep(id, cue.id, { note: 'gate opened, herald read' }, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.equal(stepsOf(id)[0].status, 'done', 'a person saying they did it is the step having succeeded')
+ assert.equal(stepsOf(id)[0].last_error, 'gate opened, herald read')
+ assert.equal(lastLog().detail.control, 'confirm')
+ assert.equal(lastLog().detail.by, ACTOR)
+})
+
+test('confirm cannot resolve a step a process is dispatching', async () => {
+ // The whole vocabulary here is "running with a NULL lease". A live lease means
+ // something is mid-dispatch, and confirming it would race the process that
+ // owns the row.
+ const id = seedRun({ status: 'running', steps: [{ status: 'running', leased: true }] })
+ const [busy] = stepsOf(id)
+
+ const result = await controls.confirmStep(id, busy.id, {}, ACTOR)
+
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 409)
+ assert.equal(stepsOf(id)[0].status, 'running')
+})
+
+test('a step id from another run is a 404, not an action', async () => {
+ const mine = seedRun({ status: 'running', steps: [{ status: 'pending' }] })
+ const theirs = seedRun({ status: 'running', steps: [{ status: 'running' }] })
+ const [theirStep] = stepsOf(theirs)
+
+ const result = await controls.confirmStep(mine, theirStep.id, {}, ACTOR)
+
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 404)
+ assert.equal(stepsOf(theirs)[0].status, 'running')
+})
+
+// ── skip ───────────────────────────────────────────────────────────────────
+
+test('skip takes a pending step and a parked cue, and nothing else', async () => {
+ const id = seedRun({
+ status: 'running',
+ steps: [{ status: 'pending' }, { status: 'running' }, { status: 'running', leased: true }, { status: 'failed' }],
+ })
+ const [pending, parked, leased, failed] = stepsOf(id)
+
+ assert.equal((await controls.skipStep(id, pending.id, {}, ACTOR)).ok, true)
+ assert.equal((await controls.skipStep(id, parked.id, {}, ACTOR)).ok, true)
+ assert.equal((await controls.skipStep(id, leased.id, {}, ACTOR)).ok, false)
+ // A failed step does not need skipping: `nextOpenStep` already passes over it,
+ // so resuming the run carries the phase past it.
+ assert.equal((await controls.skipStep(id, failed.id, {}, ACTOR)).ok, false)
+
+ const after = stepsOf(id)
+ assert.equal(after[0].status, 'skipped')
+ assert.equal(after[1].status, 'skipped')
+ assert.equal(after[2].status, 'running')
+ assert.equal(after[3].status, 'failed')
+})
+
+test('skip refuses once the run is over', async () => {
+ const id = seedRun({ status: 'completed', steps: [{ status: 'pending' }] })
+ const [step] = stepsOf(id)
+ assert.equal((await controls.skipStep(id, step.id, {}, ACTOR)).ok, false)
+})
+
+// ── retry ──────────────────────────────────────────────────────────────────
+
+test('retry re-queues the step a paused run is stopped at, and resumes in the same action', async () => {
+ const id = seedRun({
+ status: 'paused',
+ steps: [{ status: 'done' }, { status: 'failed', attempts: 3 }, { status: 'pending' }],
+ })
+ const failed = stepsOf(id)[1]
+
+ const result = await controls.retryStep(id, failed.id, {}, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.equal(result.resumed, true)
+ assert.equal(stepsOf(id)[1].status, 'pending')
+ assert.equal(stepsOf(id)[1].attempts, 0, 'the ceiling bounds the runner, not a person deciding once')
+ assert.equal(runRow(id).status, 'running', 'there is no state in which you would want half of this')
+})
+
+test('retry refuses a step the run has already moved past', async () => {
+ // The case the guard exists for: a failed step under an `on_failure` of `skip`
+ // is one the phase carried on from. Re-queueing it would put a pending row
+ // behind the runner's cursor, where it would sit for ever.
+ const id = seedRun({
+ status: 'paused',
+ steps: [{ status: 'failed', attempts: 3 }, { status: 'done' }, { status: 'failed', attempts: 3 }],
+ })
+ const [movedPast] = stepsOf(id)
+
+ const result = await controls.retryStep(id, movedPast.id, {}, ACTOR)
+
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 409)
+ assert.match(result.errors[0], /stopped at this step/)
+ assert.equal(stepsOf(id)[0].status, 'failed')
+ assert.equal(runRow(id).status, 'paused', 'a refused retry does not resume the run either')
+})
+
+test('retry refuses a step in a phase the run has left', async () => {
+ const id = seedRun({
+ status: 'paused',
+ phase: 'two',
+ steps: [{ phase: 'one', seq: 0, status: 'failed' }, { phase: 'two', seq: 0, status: 'pending' }],
+ })
+ const [old] = stepsOf(id)
+
+ const result = await controls.retryStep(id, old.id, {}, ACTOR)
+ assert.equal(result.ok, false)
+ assert.match(result.errors[0], /already left/)
+})
+
+test('retry refuses while the run is still running', async () => {
+ const id = seedRun({ status: 'running', steps: [{ status: 'failed' }] })
+ const [failed] = stepsOf(id)
+
+ const result = await controls.retryStep(id, failed.id, {}, ACTOR)
+ assert.equal(result.ok, false)
+ assert.match(result.errors[0], /paused/)
+})
+
+test('retry refuses a step that is not failed', async () => {
+ const id = seedRun({ status: 'paused', steps: [{ status: 'pending' }] })
+ const [pending] = stepsOf(id)
+ assert.equal((await controls.retryStep(id, pending.id, {}, ACTOR)).ok, false)
+})
+
+// ── the record ─────────────────────────────────────────────────────────────
+
+test('every control writes one log line carrying the actor and the control name', async () => {
+ const id = seedRun({ status: 'running', steps: [{ status: 'running' }, { status: 'pending' }] })
+ const [parked, pending] = stepsOf(id)
+
+ await controls.confirmStep(id, parked.id, { note: 'done' }, ACTOR)
+ await controls.skipStep(id, pending.id, { reason: 'not needed' }, ACTOR)
+ await controls.pause(id, {}, ACTOR)
+ await controls.resume(id, {}, ACTOR)
+ await controls.cancel(id, { reason: 'over' }, ACTOR)
+
+ const human = store.log.filter((l) => l.detail?.control)
+ assert.deepEqual(human.map((l) => l.detail.control), ['confirm', 'skip', 'pause', 'resume', 'cancel'])
+ assert.ok(human.every((l) => l.detail.by === ACTOR))
+ // The kinds are the ones a reader already scans for. A human transition is
+ // still a transition; `detail.control` is what separates it from the runner's.
+ assert.deepEqual([...new Set(human.map((l) => l.kind))].sort(), ['run.status', 'step.status'])
+})
+
+test('an empty reason is stored as NULL rather than as an empty string', async () => {
+ const id = seedRun({ status: 'running' })
+ await controls.pause(id, { reason: ' ' }, ACTOR)
+ assert.equal(lastLog().detail.reason, null)
+})
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
index 33b1abf..281cdd7 100644
--- a/server/test/eventRunner.test.js
+++ b/server/test/eventRunner.test.js
@@ -131,6 +131,8 @@ function installStubs() {
return true
}
+ runsDb.statusOf = async (id) => store.runs.get(id)?.status || null
+
runsDb.setHealth = async (id, health) => {
const r = store.runs.get(id)
if (!r || r.health === health) return false
@@ -687,3 +689,51 @@ test('the dispatch envelope carries what §F says it carries', async () => {
assert.equal(envelope.idempotencyKey.length, 40)
assert.deepEqual(Object.keys(envelope).sort(), ['actor', 'idempotencyKey', 'params', 'runId', 'scope', 'stepId', 'verify'])
})
+
+test('a run paused mid-batch stops there rather than draining the rest of the phase', async () => {
+ // The whole value of a pause is that it takes effect NOW. `advanceRun` drains
+ // up to STEPS_PER_TICK steps from one run inside a single tick, so a status
+ // re-read only at the top of the tick would answer a pause by dispatching
+ // another two dozen steps. Written by pausing from inside an action's own
+ // `perform`, which is the only moment that race is reproducible.
+ register([
+ scriptedAction('test.pauser', {
+ perform: async ({ runId }) => {
+ await runsDb.transition(runId, ['starting', 'running'], 'paused', { clearClaim: true })
+ return { ok: true }
+ },
+ }),
+ scriptedAction('test.after'),
+ ])
+
+ const id = seedRun([
+ {
+ key: 'main',
+ label: 'Main',
+ steps: [step('test.pauser'), step('test.after'), step('test.after')],
+ },
+ ])
+
+ await runner.tick(T0)
+
+ assert.equal(run(id).status, 'paused')
+ const [first, second, third] = stepsOf(id)
+ assert.equal(first.status, 'done', 'the step that was already dispatched finishes')
+ assert.equal(second.status, 'pending', 'nothing after it ran')
+ assert.equal(third.status, 'pending')
+ assert.equal(scripted['test.after'], undefined, 'the later action was never called')
+})
+
+test('a resumed run picks up from the step it stopped at', async () => {
+ register([scriptedAction('test.a'), scriptedAction('test.b')])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.a'), step('test.b')] }])
+ await runner.tick(T0)
+ assert.equal(run(id).status, 'completed')
+
+ // And the mirror of it: a run parked at `paused` is not picked up at all, which
+ // is what `findDue`'s omission of the status buys.
+ const other = seedRun([{ key: 'main', label: 'Main', steps: [step('test.a')] }], { status: 'paused' })
+ await runner.tick(T0)
+ assert.equal(stepsOf(other)[0].status, 'pending', 'a paused run is not swept')
+})
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
index 5db23a5..51bdda3 100644
--- a/server/test/eventRunnerSql.test.js
+++ b/server/test/eventRunnerSql.test.js
@@ -25,6 +25,19 @@
// both a MariaDB-specific syntax and a correctness claim: it must move the
// next PENDING step and only ever push a due date later.
//
+// **Phase 3 added four more**, and each of them is a control a staff member
+// presses against a live game world:
+//
+// * **`confirmParked` / `skipByHuman`** - both keyed on `status = 'running'
+// AND claim_expires_at IS NULL`. That pair, and only that pair, means "a cue
+// waiting on a human". If the clause let a LEASED step through, confirm would
+// race the process mid-dispatch on that row.
+// * **`cancelOpen`** - pending steps and parked cues, never a leased one.
+// * **`lastStartedSeq`** - a `MAX(seq) ... WHERE status <> 'pending'`, which is
+// what decides whether retry is offered. The first draft asked for the LOWEST
+// unsettled seq instead, which is a different step whenever a phase carried
+// on past an `on_failure: skip` failure.
+//
// Plus the two unique indexes that are load-bearing rather than tidy:
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
@@ -150,6 +163,36 @@ UPDATE event_run_steps
AND (due_at IS NULL OR due_at < ?)
ORDER BY seq LIMIT 1`
+// Phase 3's four, verbatim from `eventRunSteps.db.js`.
+const CONFIRM_PARKED = `
+UPDATE event_run_steps
+ SET status = 'done', finished_at = NOW(), claimed_by = NULL,
+ last_error = ?
+ WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`
+
+const SKIP_BY_HUMAN = `
+UPDATE event_run_steps
+ SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
+ last_error = ?
+ WHERE id = ?
+ AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
+
+const REQUEUE = `
+UPDATE event_run_steps
+ SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
+ claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
+ WHERE id = ? AND status = 'failed'`
+
+const CANCEL_OPEN = `
+UPDATE event_run_steps
+ SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
+ WHERE run_id = ?
+ AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`
+
+const LAST_STARTED_SEQ = `
+SELECT MAX(seq) AS seq FROM event_run_steps
+ WHERE run_id = ? AND phase = ? AND status <> 'pending'`
+
const MATERIALISE_RUN = `
INSERT IGNORE INTO event_runs (definition_id, version_id, scope, scheduled_for, concurrency_key)
VALUES (?, ?, ?, ?, ?)`
@@ -506,3 +549,113 @@ test('findMissed compares against each definition’s own grace window', async (
assert.deepEqual(missed, [tight.runId])
assert.ok(!missed.includes(generous.runId), 'inside its own window a run starts late rather than being missed')
})
+
+// -- Phase 3: the controls a human presses ----------------------------------
+
+test('confirm resolves a parked cue and cannot touch a step being dispatched', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const parked = await seedStep(runId, { seq: 0, status: 'running', claimedBy: 'host:1', claimExpiresAt: null })
+ const busy = await seedStep(runId, { seq: 1, status: 'running', claimedBy: 'host:1', claimExpiresAt: later(60_000), key: 'x'.repeat(40) })
+
+ assert.equal(rows(await pool.query(CONFIRM_PARKED, ['gate opened', parked])), 1)
+ assert.equal(rows(await pool.query(CONFIRM_PARKED, ['nope', busy])), 0, 'a live lease is a step somebody owns')
+
+ assert.equal((await stepById(parked)).status, 'done')
+ assert.equal((await stepById(parked)).last_error, 'gate opened')
+ assert.equal((await stepById(busy)).status, 'running')
+})
+
+test('a confirm of an already-confirmed cue reports 0, not 1', async (t) => {
+ if (needDb(t)) return
+ // The engagement Phase 4a shape: a connector that defaults `foundRows: true`
+ // reports 1 for an UPDATE that matched and changed nothing, and a control that
+ // read that as success would tell a second staff member their press worked.
+ const { runId } = await seedRun({ status: 'running' })
+ const parked = await seedStep(runId, { status: 'running', claimExpiresAt: null })
+
+ assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 1)
+ assert.equal(rows(await pool.query(CONFIRM_PARKED, [null, parked])), 0)
+})
+
+test('skip takes a pending step and a parked cue, and refuses a leased one', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const pending = await seedStep(runId, { seq: 0, status: 'pending' })
+ const parked = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: null, key: 'y'.repeat(40) })
+ const busy = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: later(60_000), key: 'z'.repeat(40) })
+ const failed = await seedStep(runId, { seq: 3, status: 'failed', key: 'w'.repeat(40) })
+
+ assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, pending])), 1)
+ assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, parked])), 1)
+ assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, busy])), 0)
+ assert.equal(rows(await pool.query(SKIP_BY_HUMAN, [null, failed])), 0, 'a failed step is terminal; resume carries the phase past it')
+})
+
+test('cancelOpen closes pending steps and parked cues, and leaves a leased one alone', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ const done = await seedStep(runId, { seq: 0, status: 'done' })
+ const busy = await seedStep(runId, { seq: 1, status: 'running', claimExpiresAt: later(60_000), key: 'p'.repeat(40) })
+ const parked = await seedStep(runId, { seq: 2, status: 'running', claimExpiresAt: null, key: 'q'.repeat(40) })
+ const pending = await seedStep(runId, { seq: 3, status: 'pending', key: 'r'.repeat(40) })
+
+ assert.equal(rows(await pool.query(CANCEL_OPEN, [runId])), 2)
+
+ assert.equal((await stepById(done)).status, 'done')
+ assert.equal((await stepById(busy)).status, 'running', 'nothing can recall a command already sent')
+ assert.equal((await stepById(parked)).status, 'cancelled', 'a cancelled run must stop claiming to wait on somebody')
+ assert.equal((await stepById(pending)).status, 'cancelled')
+})
+
+test('requeue only takes a failed step, and puts attempts back to zero', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'paused' })
+ const failed = await seedStep(runId, { seq: 0, status: 'failed', attempts: 3 })
+ const pending = await seedStep(runId, { seq: 1, status: 'pending', key: 's'.repeat(40) })
+
+ assert.equal(rows(await pool.query(REQUEUE, [failed])), 1)
+ assert.equal(rows(await pool.query(REQUEUE, [pending])), 0)
+
+ const row = await stepById(failed)
+ assert.equal(row.status, 'pending')
+ assert.equal(Number(row.attempts), 0)
+ assert.equal(row.due_at, null, 'a re-queued step is due now, not at the retry backoff it was left on')
+})
+
+test('lastStartedSeq names the furthest step of the phase, not the earliest unsettled one', async (t) => {
+ if (needDb(t)) return
+ // The defect this replaced: a phase that carried on past a failed step (an
+ // `on_failure` of `skip`) and then paused at a later one. "The lowest seq that
+ // is not settled" answers with the FIRST failure - a step the runner has long
+ // since stepped over - and retry would re-queue a row behind its own cursor.
+ const { runId } = await seedRun({ status: 'paused' })
+ await seedStep(runId, { seq: 0, status: 'failed', key: 'a'.repeat(40) })
+ await seedStep(runId, { seq: 1, status: 'done', key: 'b'.repeat(40) })
+ await seedStep(runId, { seq: 2, status: 'failed', key: 'c'.repeat(40) })
+ await seedStep(runId, { seq: 3, status: 'pending', key: 'd'.repeat(40) })
+
+ const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
+ assert.equal(Number(row.seq), 2)
+})
+
+test('lastStartedSeq is NULL for a phase nothing has touched', async (t) => {
+ if (needDb(t)) return
+ const { runId } = await seedRun({ status: 'running' })
+ await seedStep(runId, { seq: 0, status: 'pending' })
+
+ const [row] = await pool.query(LAST_STARTED_SEQ, [runId, 'main'])
+ assert.equal(row.seq, null, 'a null must read as "nothing to retry", not as seq 0')
+})
+
+test('a guarded transition refuses a run that was cancelled underneath it', async (t) => {
+ if (needDb(t)) return
+ // What the admin cancel looks like from the runner's side, mid-tick: the
+ // guarded write returns 0 and the tick treats the run as taken rather than
+ // advancing a run somebody has just stopped.
+ const { runId } = await seedRun({ status: 'running' })
+ await pool.query("UPDATE event_runs SET status = 'cancelled' WHERE id = ?", [runId])
+
+ assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
+ assert.equal((await runById(runId)).status, 'cancelled')
+})
--
2.49.1
From 6e73660b52b5484879e4a94eff18d247d7c09506 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 2 Sep 2026 16:10:16 -0500
Subject: [PATCH 04/18] feat(events): schedule, recurrence and the calendar
(Phase 4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.
No schema change: Phase 1 built every column this needed.
- events/recurrence.js is the ONE place an occurrence is computed, so the
runner's expansion and the calendar's forecast cannot disagree. No date
library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
window starts at `now - grace`, so an occurrence nobody could have seen is
never invented retroactively; the horizon is what makes the missed sweep
mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
occurrence does not reappear as a forecast.
54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.
Docs: RunicGateway/docs#PENDING
Co-Authored-By: Claude
---
client/src/App.jsx | 2 +
client/src/api/client.js | 18 +
client/src/lib/eventAuthoring.js | 129 ++++-
client/src/routes/admin/AdminLayout.jsx | 5 +
client/src/routes/admin/views/EventEditor.jsx | 106 +++-
client/src/routes/admin/views/EventsAdmin.jsx | 15 +-
.../src/routes/admin/views/EventsCalendar.jsx | 457 ++++++++++++++++++
client/test/eventAuthoring.test.js | 108 +++++
server/routes.guards.json | 36 ++
server/routes.manifest.json | 16 +
server/src/events/recurrence.js | 335 +++++++++++++
server/src/events/spec.js | 140 +++++-
.../src/model/events/eventCalendar.model.js | 170 +++++++
.../src/model/events/eventDefinitions.db.js | 34 ++
.../model/events/eventDefinitions.model.js | 27 +-
server/src/model/events/eventRuns.db.js | 89 ++++
server/src/model/events/eventRuns.model.js | 21 +-
server/src/model/events/eventSeries.db.js | 66 ++-
server/src/model/events/eventSeries.model.js | 93 ++++
.../src/router/v1/admin/events.controller.js | 89 +++-
server/src/router/v1/admin/events.router.js | 76 ++-
server/src/utils/eventRunner.js | 140 +++++-
server/swagger/swagger-output.json | 436 ++++++++++++++++-
server/test/eventRecurrence.test.js | 273 +++++++++++
server/test/eventRunner.test.js | 11 +-
server/test/eventRunnerSql.test.js | 232 +++++++++
server/test/eventSchedule.test.js | 391 +++++++++++++++
server/test/eventSeries.test.js | 113 +++++
server/test/eventSpec.test.js | 103 +++-
server/test/eventsAdmin.test.js | 68 +++
30 files changed, 3722 insertions(+), 77 deletions(-)
create mode 100644 client/src/routes/admin/views/EventsCalendar.jsx
create mode 100644 server/src/events/recurrence.js
create mode 100644 server/src/model/events/eventCalendar.model.js
create mode 100644 server/src/model/events/eventSeries.model.js
create mode 100644 server/test/eventRecurrence.test.js
create mode 100644 server/test/eventSchedule.test.js
create mode 100644 server/test/eventSeries.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index aef41a0..311d326 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -50,6 +50,7 @@ import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
+import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
import EventEditor from './routes/admin/views/EventEditor.jsx'
import EventRun from './routes/admin/views/EventRun.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
@@ -204,6 +205,7 @@ export default function App() {
route it calls. `runs/:runId` is declared before `:id` so the
literal segment is never read as a definition id. */}
} />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index a6e0a15..8f979af 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -487,6 +487,24 @@ export const api = {
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
eventCatalog: () => req('/admin/events/catalog'),
eventSeries: () => req('/admin/events/series'),
+ // Series writes are admin+editor rather than admin: naming an arc is
+ // authoring, and §N2's narrow gate is about committing the deployment to a
+ // run. The delete is a real delete and answers with how many definitions it
+ // detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
+ createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
+ updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
+ deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
+ // The calendar. `from`/`to` are UTC instants the caller computes from the
+ // month it is showing, in the READER's zone — the server never guesses it.
+ // A `status` or `scope` filter suppresses projections, which is why the
+ // month view sends neither.
+ eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
+ const qs = new URLSearchParams({ from, to })
+ if (status) qs.set('status', status)
+ if (scope) qs.set('scope', scope)
+ if (seriesId) qs.set('seriesId', String(seriesId))
+ return req(`/admin/events/calendar?${qs.toString()}`)
+ },
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
listEventRuns: ({ definitionId, status, limit } = {}) => {
const qs = new URLSearchParams()
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 8ce3179..77b8073 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -141,7 +141,7 @@ export function formFromDefinition(event) {
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
- scheduleKind: spec.schedule?.kind || 'manual',
+ ...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
@@ -204,11 +204,136 @@ export function payloadFromForm(form) {
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
- spec: { schedule: { kind: form.scheduleKind || 'manual' }, phases },
+ spec: { schedule: scheduleFromForm(form), phases },
},
}
}
+
+// ── The schedule (Phase 4) ─────────────────────────────────────────────────
+//
+// The four closed shapes of §E, mirrored so the form can render one and the
+// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
+// the deciders — this is what makes the form a form rather than a text box, and
+// it is the whole reason the schedule is not a cron string: a closed set has a
+// dropdown, and an operator can proofread a dropdown.
+
+export const WEEKDAYS = [
+ 'sunday',
+ 'monday',
+ 'tuesday',
+ 'wednesday',
+ 'thursday',
+ 'friday',
+ 'saturday',
+]
+
+export const SCHEDULE_KINDS = [
+ { value: 'manual', label: 'Started by hand' },
+ { value: 'once', label: 'Once, at a set time' },
+ { value: 'weekly', label: 'Weekly, on chosen days' },
+ { value: 'monthly', label: 'Monthly, on the nth weekday' },
+]
+
+// 1..4 and "last". There is no fifth: every month has a first through fourth of
+// every weekday, and "last" is what a month with five Fridays makes different
+// from "fourth" (org lead, 2026-09-02).
+export const MONTHLY_NTHS = [
+ { value: 1, label: 'First' },
+ { value: 2, label: 'Second' },
+ { value: 3, label: 'Third' },
+ { value: 4, label: 'Fourth' },
+ { value: -1, label: 'Last' },
+]
+
+const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
+
+/**
+ * A schedule in words, in the event's own zone.
+ *
+ * The server says the same thing in `events/recurrence.js#describe`, and the two
+ * are allowed to differ on wording but not on meaning — this one is what an
+ * author reads while they are still typing, before anything has been saved.
+ */
+export function describeSchedule(schedule, timezone = 'UTC') {
+ if (!schedule || typeof schedule !== 'object') return 'No schedule'
+ const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
+ switch (schedule.kind) {
+ case 'manual':
+ return 'Started by hand — nothing happens until an admin presses Start'
+ case 'once': {
+ if (!schedule.at) return 'Once — no date chosen yet'
+ return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
+ }
+ case 'weekly': {
+ const days = (schedule.days || []).map(capitalise)
+ if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
+ const list =
+ days.length === 1
+ ? days[0]
+ : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
+ return `Every ${list} at ${schedule.time} (${timezone})`
+ }
+ case 'monthly': {
+ if (!nth || !schedule.weekday || !schedule.time) {
+ return 'Monthly — choose a week, a weekday and a time'
+ }
+ return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
+ }
+ default:
+ return 'No schedule'
+ }
+}
+
+/**
+ * The schedule half of the editor's working state.
+ *
+ * Every shape's fields are kept side by side rather than cleared when the kind
+ * changes, so an author who clicks Weekly, then Monthly, then back has not lost
+ * the days they picked. `scheduleFromForm` reads only the fields the chosen kind
+ * uses, which is what keeps the request body a clean single shape.
+ */
+export function scheduleFormFrom(schedule) {
+ const s = schedule || {}
+ return {
+ scheduleKind: s.kind || 'manual',
+ scheduleAt: s.kind === 'once' ? s.at || '' : '',
+ scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
+ scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
+ scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
+ scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
+ }
+}
+
+/** The schedule the form describes, as the spec object the server expects. */
+export function scheduleFromForm(form) {
+ switch (form.scheduleKind) {
+ case 'once':
+ return { kind: 'once', at: form.scheduleAt }
+ case 'weekly':
+ return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
+ case 'monthly':
+ return {
+ kind: 'monthly',
+ nth: Number(form.scheduleNth),
+ weekday: form.scheduleWeekday,
+ time: form.scheduleTime,
+ }
+ default:
+ return { kind: 'manual' }
+ }
+}
+
+/**
+ * What a calendar entry is, and therefore what may be done with it.
+ *
+ * A `run` is a row: it has a console and somebody can cancel it. A `projected`
+ * entry is arithmetic the runner has not reached yet — there is nothing to open
+ * and nothing to stop, and an operator who treats one as a booking has been
+ * misled by the UI rather than by the server.
+ */
+export const isProjected = (entry) => entry?.kind === 'projected'
+
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
export function parseParams(text) {
const raw = (text || '').trim()
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index d489c5e..db77552 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -133,6 +133,10 @@ export const NAV = [
title: 'Events',
items: [
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
+ // Phase 4. The same staff gate as the list beside it: a calendar is a read,
+ // and the arcs it manages are authoring gated on the buttons rather than
+ // on the row.
+ { to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
],
},
{
@@ -218,6 +222,7 @@ const TITLES = {
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
'/admin/events': 'Events',
+ '/admin/events/calendar': 'Event calendar',
'/admin/events/new': 'New event',
}
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index 3ab4d99..2e37ff6 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -8,6 +8,11 @@ import {
payloadFromForm,
blankPhase,
blankStep,
+ describeSchedule,
+ scheduleFromForm,
+ SCHEDULE_KINDS,
+ MONTHLY_NTHS,
+ WEEKDAYS,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
@@ -191,7 +196,13 @@ export default function EventEditor() {
const result = await api.admin.publishEvent(id)
setEvent(result.event)
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
- setNotice(`Published as v${result.version}.`)
+ // The re-pin count is said out loud, because an editor who does not know
+ // their fix reached next Friday finds out on Friday.
+ setNotice(
+ result.repinned
+ ? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
+ : `Published as v${result.version}.`,
+ )
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
@@ -329,13 +340,94 @@ export default function EventEditor() {
{/* ── Schedule ── */}
+ {/*
+ Four closed shapes rendered as a form, never a cron string. A cron
+ expression is the one field an operator cannot proofread, and the whole
+ point of the closed set is that this panel can be read back in English —
+ which is what the preview line under it does.
+ */}
-
Schedule
-
- Started by hand. Recurrence — once, weekly, monthly on the nth weekday —
- is computed in the event’s own timezone and arrives in the next phase, with the calendar.
- Until then an occurrence exists because somebody pressed Start now, and the
- schedule shape a definition may carry is deliberately the single one the runner honours.
+
+ Times are the event’s own, in {form.timezone} — not the reader’s. A
+ recurring schedule goes live when the definition is published and stops when it is
+ archived; occurrences become real runs a fortnight before they happen, and the
+ calendar forecasts the rest. A time that daylight saving skips moves forward to the next
+ one that exists, and an hour that happens twice takes the first.
diff --git a/client/src/routes/admin/views/EventsAdmin.jsx b/client/src/routes/admin/views/EventsAdmin.jsx
index 1153211..8fcc472 100644
--- a/client/src/routes/admin/views/EventsAdmin.jsx
+++ b/client/src/routes/admin/views/EventsAdmin.jsx
@@ -20,9 +20,10 @@ import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
// it before it can say so.
//
-// What is NOT here: a calendar. Recurrence and the month view are Phase 4, and a
-// definition today can only carry `schedule: { kind: 'manual' }` — so the honest
-// list is a list, and the screen says as much rather than showing an empty grid.
+// **The calendar is a separate screen, not a third table here.** It answers
+// "when", this one answers "what" — and Phase 4, which built it, also made a
+// definition able to carry a recurrence, so the two questions stopped having the
+// same answer the moment an occurrence could exist before anybody pressed Start.
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
@@ -108,8 +109,9 @@ export default function EventsAdmin() {
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
published as an immutable version, and every occurrence of it runs against the version it
- pinned. Recurrence and the calendar arrive with the next phase — for now an occurrence is
- started by hand.
+ pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
+ own timezone — and the calendar is where those
+ occurrences are read.
+
+ Calendar
+
{mayAuthor && (
New event
diff --git a/client/src/routes/admin/views/EventsCalendar.jsx b/client/src/routes/admin/views/EventsCalendar.jsx
new file mode 100644
index 0000000..a96ec3b
--- /dev/null
+++ b/client/src/routes/admin/views/EventsCalendar.jsx
@@ -0,0 +1,457 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { useAuth } from '../../../contexts/AuthContext.jsx'
+import { api } from '../../../api/client.js'
+import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js'
+
+// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
+//
+// **This screen is the deliverable.** What this feature replaces is a WordPress
+// calendar plugin with no series field, no recurrence and no results — so a
+// month grid that knows about arcs, repeats and local time is not decoration
+// here, it is the point.
+//
+// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
+// a real row with a status, a pinned version and a console, and somebody can
+// cancel it. A dashed one is a *projection*: arithmetic past the runner's
+// fourteen-day horizon, with no row behind it, nothing committed and nothing to
+// open. An operator who treats a forecast as a booking has been misled by the
+// UI, not by the server, so the difference is drawn rather than merely stated —
+// and the legend says which is which.
+//
+// **The grid's date axis is the READER's zone; each entry's time is the
+// EVENT's.** §E gives the timezone to the event because every listing this
+// replaces is written in the shard's local zone, but "what is happening this
+// month" is a question about the month the person reading is living in. So the
+// cell an event lands in is the reader's date, and the time beside it always
+// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
+
+const DAY_MS = 86_400_000
+
+const STATUS_COLOR = {
+ failed: '#d98b84',
+ missed: '#d98b84',
+ paused: '#d9c184',
+ cancelled: 'var(--muted)',
+ running: '#8fc79a',
+}
+
+/** The event's own wall clock, which is the only time worth showing beside it. */
+function localTime(instant, timezone) {
+ try {
+ return new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).format(new Date(instant))
+ } catch {
+ return new Date(instant).toISOString().slice(11, 16)
+ }
+}
+
+/** The reader's own date key, which is what places an entry in a cell. */
+const readerDayKey = (instant) => {
+ const d = new Date(instant)
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
+}
+
+/**
+ * The six-week grid a month view draws, Monday first.
+ *
+ * Always six weeks rather than however many the month needs: a grid that
+ * changes height as you page through it is a grid whose rows move under the
+ * cursor.
+ */
+function monthGrid(year, month) {
+ const first = new Date(year, month, 1)
+ const offset = (first.getDay() + 6) % 7
+ const start = new Date(year, month, 1 - offset)
+ return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
+}
+
+const MONTH_NAMES = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December',
+]
+
+export default function EventsCalendar() {
+ const { user } = useAuth()
+ const today = useMemo(() => new Date(), [])
+ const [year, setYear] = useState(today.getFullYear())
+ const [month, setMonth] = useState(today.getMonth())
+ const [view, setView] = useState('month')
+ const [seriesId, setSeriesId] = useState('')
+ const [series, setSeries] = useState([])
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [managingSeries, setManagingSeries] = useState(false)
+
+ const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
+
+ // The window is the grid's own span, not the month's: an entry in the leading
+ // or trailing week of the grid belongs to a neighbouring month and still has
+ // to be fetched, or the first row of every month renders empty.
+ const grid = useMemo(() => monthGrid(year, month), [year, month])
+ const window = useMemo(() => {
+ if (view === 'month') {
+ return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
+ }
+ // The list view answers a different question — "what is coming" — so it runs
+ // forward from now rather than over a calendar month.
+ const from = new Date()
+ return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
+ }, [view, grid])
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [calendar, seriesList] = await Promise.all([
+ api.admin.eventCalendar({
+ from: window.from.toISOString(),
+ to: window.to.toISOString(),
+ seriesId: seriesId || undefined,
+ }),
+ api.admin.eventSeries(),
+ ])
+ setData(calendar)
+ setSeries(seriesList.series || [])
+ } catch (err) {
+ setError(err)
+ } finally {
+ setLoading(false)
+ }
+ }, [window.from, window.to, seriesId])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const byDay = useMemo(() => {
+ const map = new Map()
+ for (const entry of data?.entries || []) {
+ const key = readerDayKey(entry.scheduledFor)
+ if (!map.has(key)) map.set(key, [])
+ map.get(key).push(entry)
+ }
+ return map
+ }, [data])
+
+ const step = (delta) => {
+ const next = new Date(year, month + delta, 1)
+ setYear(next.getFullYear())
+ setMonth(next.getMonth())
+ }
+
+ if (loading && !data) return
+ if (error && !data) return
+
+ const horizon = data?.horizon ? new Date(data.horizon) : null
+
+ return (
+ <>
+
+
+ {/*
+ The stepper belongs to the MONTH view only. The list answers "what is
+ coming" and runs sixty days forward from now whatever month is
+ selected -- so paging it would be three controls that visibly do
+ nothing, which is the one thing this feature has refused since Phase
+ 1. The heading says which question is being asked instead.
+ */}
+ {view === 'month' && (
+ <>
+
+
+ {`${MONTH_NAMES[month]} ${year}`}
+
+
+
+ >
+ )}
+ {view === 'list' && (
+ The next 60 days
+ )}
+
+
+
+
+
+
+ {mayAuthor && (
+
+ )}
+ Events
+
+
+
+ {/* The legend is not optional. The whole screen rests on the reader
+ knowing that a dashed entry is not a booking. */}
+
+ Scheduled run is a real occurrence with
+ a console — it can be opened, paused and cancelled.{' '}
+ Forecast is what the
+ recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
+ committed yet and there is nothing to open.
+ {horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
+
+ {isProjected(entry) ? Forecast : runStatusWord(entry.status)}
+ {entry.waitingSteps > 0 && (
+ · waiting on a person
+ )}
+
+
+ ))}
+
+
+ )}
+
+ )}
+ >
+ )
+}
+
+// A projection has no run id, so the definition and the instant are its
+// identity — the same pair the server dedupes projections against.
+const entryKey = (entry) =>
+ entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
+
+const chip = {
+ display: 'inline-block',
+ padding: '0 5px',
+ borderRadius: 3,
+ borderWidth: 1,
+ border: '1px solid var(--muted)',
+ fontSize: '0.72rem',
+}
+
+/**
+ * The arcs, managed where they are used.
+ *
+ * A series is a label, not authored content — nothing pins one and no run
+ * references one — so this is a small inline panel rather than a screen of its
+ * own, and it lives on the calendar because the calendar is what makes an arc
+ * visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
+ * From the Void" is continuity that exists nowhere in the tooling this replaces.*
+ *
+ * The delete is a real delete, and it says what it will detach before it
+ * happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
+ * an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
+ * which is why this is the one delete in this feature that is not an archive.
+ */
+function SeriesManager({ series, onChanged }) {
+ const [name, setName] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [problem, setProblem] = useState(null)
+
+ const run = async (fn) => {
+ setBusy(true)
+ setProblem(null)
+ try {
+ await fn()
+ await onChanged()
+ } catch (err) {
+ setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
Series
+
+ An arc several events form together. The order here is where a series sits among the
+ others; where an event sits within its arc is that event’s own order, in the
+ editor.
+
+ )
+}
+
+function EntryChip({ entry }) {
+ const projected = isProjected(entry)
+ const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
+ return (
+
+ {localTime(entry.scheduledFor, entry.timezone)} {entry.title}
+ {entry.waitingSteps > 0 && ●}
+
+ )
+}
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
index a1e11c4..1fe8a9b 100644
--- a/client/test/eventAuthoring.test.js
+++ b/client/test/eventAuthoring.test.js
@@ -12,6 +12,12 @@ import {
blankPhase,
describeLogLine,
runStatusWord,
+ describeSchedule,
+ scheduleFormFrom,
+ scheduleFromForm,
+ isProjected,
+ WEEKDAYS,
+ MONTHLY_NTHS,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
@@ -308,3 +314,105 @@ test('every run status has a word, and an unknown one falls through rather than
}
assert.equal(runStatusWord('something-new'), 'something-new')
})
+
+
+// ── The schedule form (Phase 4) ─────────────────────────────────────
+//
+// The form is the whole argument against cron: a closed set of four shapes has a
+// dropdown, and a dropdown can be proofread. What is checked here is that the
+// round trip through the form does not quietly change what the author wrote —
+// the server would refuse a malformed schedule, but it cannot refuse a
+// well-formed one that says something the author did not mean.
+
+test('a schedule survives the round trip through the form unchanged', () => {
+ for (const schedule of [
+ { kind: 'manual' },
+ { kind: 'once', at: '2026-10-31T20:00' },
+ { kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
+ { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
+ ]) {
+ const form = scheduleFormFrom(schedule)
+ assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
+ }
+})
+
+test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
+ // An author who clicks Weekly, then Monthly, then back must not find the days
+ // they picked gone — but the request body must still be a single clean shape,
+ // not a union of everything they touched.
+ const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
+ const sent = scheduleFromForm(form)
+ assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
+ assert.equal(form.scheduleDays.includes('friday'), true)
+})
+
+test('formFromDefinition carries the whole schedule, not only its kind', () => {
+ const form = formFromDefinition({
+ title: 'Fishing contest',
+ timezone: 'Europe/Berlin',
+ spec: {
+ schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+ },
+ })
+ assert.equal(form.scheduleKind, 'monthly')
+ assert.equal(form.scheduleNth, '-1')
+ assert.equal(form.scheduleWeekday, 'friday')
+ assert.equal(form.scheduleTime, '19:30')
+
+ const built = payloadFromForm(form)
+ assert.equal(built.ok, true)
+ assert.deepEqual(built.payload.spec.schedule, {
+ kind: 'monthly',
+ nth: -1,
+ weekday: 'friday',
+ time: '19:30',
+ })
+})
+
+test('a definition with no schedule at all reads as manual rather than as broken', () => {
+ const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
+ assert.equal(form.scheduleKind, 'manual')
+ assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
+})
+
+test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
+ assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
+ assert.equal(
+ describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
+ 'Every Friday and Saturday at 20:00 (Europe/Berlin)',
+ )
+ assert.equal(
+ describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
+ 'The last Friday of every month at 19:30 (Asia/Kolkata)',
+ )
+ // Half-built is the state the preview spends most of its life in — an author
+ // is typing. It must prompt, never render "undefined".
+ for (const partial of [
+ { kind: 'weekly', days: [], time: '20:00' },
+ { kind: 'weekly', days: ['friday'], time: '' },
+ { kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
+ { kind: 'once', at: '' },
+ ]) {
+ const text = describeSchedule(partial, 'UTC')
+ assert.ok(text.length > 0)
+ assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
+ assert.match(text, /choose|no date/i)
+ }
+})
+
+test('the weekday and nth vocabularies match the server', () => {
+ // Verbatim `events/recurrence.js`. A client list that drifted would offer a
+ // value the server refuses, which is exactly the class of failure this file
+ // exists to catch.
+ assert.deepEqual(WEEKDAYS, [
+ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
+ ])
+ assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
+})
+
+test('a projection is told apart from a run, because only one of them can be acted on', () => {
+ assert.equal(isProjected({ kind: 'projected', runId: null }), true)
+ assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
+ assert.equal(isProjected(null), false)
+})
diff --git a/server/routes.guards.json b/server/routes.guards.json
index f7e7657..8157a41 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -491,6 +491,15 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/calendar",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/catalog",
@@ -590,6 +599,33 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/series",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/events/series/:seriesId",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/events/series/:seriesId",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/invites",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 388ae08..63c1d8e 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -217,6 +217,10 @@
"method": "GET",
"path": "/api/v1/admin/events/:id/versions"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/calendar"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/catalog"
@@ -261,6 +265,18 @@
"method": "GET",
"path": "/api/v1/admin/events/series"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/series"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/events/series/:seriesId"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/events/series/:seriesId"
+ },
{
"method": "GET",
"path": "/api/v1/admin/invites"
diff --git a/server/src/events/recurrence.js b/server/src/events/recurrence.js
new file mode 100644
index 0000000..f28439a
--- /dev/null
+++ b/server/src/events/recurrence.js
@@ -0,0 +1,335 @@
+// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
+//
+// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
+// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
+// this file answers *which UTC instants* an event happens at. Nothing else
+// computes an occurrence; the runner materialises what this returns and the
+// calendar projects what this returns, so there is exactly one arithmetic to be
+// wrong.
+//
+// **Why there is no library here.** The server's dependency tree has no date
+// library at all — no luxon, no date-fns, no tz package (check `package.json`
+// before adding one). What it does have is Node's own full tzdata behind
+// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
+// of and is already what `eventDefinitions.model.js` validates a zone name
+// against. So the arithmetic is: *format an instant into the zone's wall clock*
+// (which `Intl` does exactly) and invert that mapping by search. Everything
+// below is that one idea.
+//
+// **Why not cron.** Decided in §E and restated in the plan: there is no parser
+// in the tree, the only precedent is in the bot (another process), and a cron
+// string is the one field an operator cannot proofread. Four closed shapes
+// render as a form, and a form is checkable.
+//
+// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
+// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
+//
+// - A **nonexistent** local time — the spring-forward gap — steps forward to the
+// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
+// happens as close to the authored time as the calendar allows.
+// - An **ambiguous** local time — the fall-back hour, which happens twice —
+// takes the FIRST, the pre-transition offset.
+//
+// Both are reported back as `adjusted`, so a run can record why its clock reads
+// oddly rather than leaving an operator to discover DST for themselves at 3am.
+// Neither rule ever drops an occurrence: a weekly event happens every week.
+
+// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
+// below return. Names rather than numbers everywhere an operator can see them —
+// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
+// argument that rejected cron.
+const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
+
+// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
+// month with five Fridays has a last Friday that is not the fourth. 1..4 always
+// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
+// therefore no absent-occurrence case to define (org lead, 2026-09-02).
+const MONTHLY_NTH = [1, 2, 3, 4, -1]
+
+const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
+const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
+
+const MINUTE_MS = 60_000
+const DAY_MS = 86_400_000
+
+// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
+// historical jumps are a day, and those are line-of-date changes rather than
+// gaps in the local clock). Four hours is a bound, not an expectation: it stops
+// a malformed zone turning the search into a hang.
+const MAX_GAP_MINUTES = 240
+
+// Bounds on what one call may return. A projection window is operator-supplied
+// (the calendar's month, the horizon), and an unbounded expansion of a daily
+// schedule across a decade is how a calendar request becomes an outage.
+const MAX_OCCURRENCES = 500
+
+const formatters = new Map()
+
+function formatterFor(zone) {
+ let f = formatters.get(zone)
+ if (!f) {
+ // `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
+ // hour 24 in some ICU versions and would put every midnight event on the
+ // previous day.
+ f = new Intl.DateTimeFormat('en-US', {
+ timeZone: zone,
+ hourCycle: 'h23',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+ formatters.set(zone, f)
+ }
+ return f
+}
+
+/** The wall clock an instant reads as, in this zone. */
+function wallPartsAt(zone, ms) {
+ const parts = formatterFor(zone).formatToParts(new Date(ms))
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value)
+ return {
+ y: get('year'),
+ m: get('month'),
+ d: get('day'),
+ h: get('hour'),
+ mi: get('minute'),
+ s: get('second'),
+ }
+}
+
+/**
+ * That same wall clock as a number, by reading it as though it were UTC.
+ *
+ * This is the trick the whole file rests on: two wall clocks are equal exactly
+ * when these numbers are, and `wallMs - instant` is the zone's offset at that
+ * instant. It is never a real instant and must not be used as one.
+ */
+function wallMs(zone, ms) {
+ const p = wallPartsAt(zone, ms)
+ return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
+}
+
+const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
+
+/**
+ * Every instant that reads as this wall clock in this zone, earliest first.
+ *
+ * Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
+ * and the length of this array is how the caller tells those three apart.
+ *
+ * Sampling the offset a day either side is what makes it correct across a
+ * transition: subtracting each candidate offset gives the two instants worth
+ * testing, and the test is whether the instant formats back to what was asked.
+ */
+function instantsForWall(zone, target) {
+ const candidates = new Set([
+ target - offsetMs(zone, target - DAY_MS),
+ target - offsetMs(zone, target + DAY_MS),
+ ])
+ const valid = []
+ for (const ms of candidates) {
+ if (wallMs(zone, ms) === target) valid.push(ms)
+ }
+ return valid.sort((a, b) => a - b)
+}
+
+/**
+ * Resolve a local wall clock to a UTC instant, applying the two DST rules.
+ *
+ * `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
+ * `'gap'` when the authored time did not exist and was stepped forward, and
+ * `'ambiguous'` when it happened twice and the first was taken.
+ */
+function resolveWall(zone, y, m, d, h, mi) {
+ const target = Date.UTC(y, m - 1, d, h, mi, 0)
+ const valid = instantsForWall(zone, target)
+ if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
+ if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
+
+ // The gap. Step the WALL CLOCK forward — not the instant — until it lands on
+ // a time that exists, which is the first instant after the transition.
+ for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
+ const shifted = instantsForWall(zone, target + step * MINUTE_MS)
+ if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
+ }
+ return null
+}
+
+// ── The civil calendar ─────────────────────────────────────────────────────
+//
+// Dates with no zone attached: "the 14th of September" as a thing to iterate,
+// before any question of what instant it starts at. `Date.UTC` is used purely
+// as calendar arithmetic here and none of these numbers is an instant.
+
+const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
+
+function civilFromIndex(n) {
+ const dt = new Date(n * DAY_MS)
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
+}
+
+const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
+
+const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
+
+/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
+const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
+
+/**
+ * The day of the month that is the nth (or last) given weekday.
+ *
+ * `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
+ * the validated shapes never produce — the guard is here so that a spec written
+ * by hand into the database cannot make the runner throw.
+ */
+function nthWeekdayDay(y, m, weekday, nth) {
+ const last = daysInMonth(y, m)
+ if (nth === -1) {
+ const back = (weekdayOf(y, m, last) - weekday + 7) % 7
+ return last - back
+ }
+ const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
+ const day = 1 + forward + (nth - 1) * 7
+ return day <= last ? day : null
+}
+
+// ── Expansion ──────────────────────────────────────────────────────────────
+
+/**
+ * Every occurrence of `schedule` in `[from, to)`, earliest first.
+ *
+ * `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
+ * shape that means "there is no recurrence", and an admin's own
+ * `POST /:id/runs` is the only thing that creates one of its occurrences.
+ *
+ * The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
+ * walk starts a day early and ends a day late: a local day can begin up to
+ * fourteen hours either side of the same UTC day.
+ */
+function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
+ const fromMs = from instanceof Date ? from.getTime() : Number(from)
+ const toMs = to instanceof Date ? to.getTime() : Number(to)
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
+ if (!schedule || typeof schedule !== 'object') return []
+
+ const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
+ const out = []
+ const keep = (resolved) => {
+ if (!resolved) return
+ const t = resolved.at.getTime()
+ if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
+ }
+
+ if (schedule.kind === 'manual') return []
+
+ if (schedule.kind === 'once') {
+ const m = AT_RE.exec(String(schedule.at || ''))
+ if (!m) return []
+ keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
+ return out
+ }
+
+ const time = TIME_RE.exec(String(schedule.time || ''))
+ if (!time) return []
+ const hour = Number(time[1])
+ const minute = Number(time[2])
+
+ if (schedule.kind === 'weekly') {
+ const wanted = new Set(
+ (schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
+ )
+ if (!wanted.size) return []
+ const first = wallPartsAt(zone, fromMs)
+ const last = wallPartsAt(zone, toMs)
+ const startDay = dayIndex(first.y, first.m, first.d) - 1
+ const endDay = dayIndex(last.y, last.m, last.d) + 1
+ for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
+ const { y, m, d } = civilFromIndex(n)
+ if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
+ }
+ return out
+ }
+
+ if (schedule.kind === 'monthly') {
+ const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
+ const nth = Number(schedule.nth)
+ if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
+ const first = wallPartsAt(zone, fromMs)
+ const last = wallPartsAt(zone, toMs)
+ // Months as a single running count, so a window crossing a new year is not
+ // a special case.
+ const startMonth = first.y * 12 + (first.m - 1) - 1
+ const endMonth = last.y * 12 + (last.m - 1) + 1
+ for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
+ const y = Math.floor(n / 12)
+ const m = (n % 12) + 1
+ const day = nthWeekdayDay(y, m, weekday, nth)
+ if (day) keep(resolveWall(zone, y, m, day, hour, minute))
+ }
+ return out
+ }
+
+ return []
+}
+
+/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
+function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
+ const fromMs = from instanceof Date ? from.getTime() : Number(from)
+ const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
+ limit: 1,
+ })
+ return first || null
+}
+
+/**
+ * How a schedule reads to a person, in the event's own zone.
+ *
+ * Server-side because two surfaces need the same sentence — the calendar's list
+ * and the run's own record of why it exists — and because the client's copy in
+ * `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
+ * meaning.
+ */
+function describe(schedule, zone = 'UTC') {
+ if (!schedule || typeof schedule !== 'object') return 'No schedule'
+ const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
+ const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
+ switch (schedule.kind) {
+ case 'manual':
+ return 'Started by hand'
+ case 'once':
+ return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
+ case 'weekly': {
+ const days = (schedule.days || []).map(cap)
+ const list =
+ days.length <= 1
+ ? days.join('')
+ : `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
+ return `Every ${list} at ${schedule.time} (${zone})`
+ }
+ case 'monthly':
+ return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
+ default:
+ return 'No schedule'
+ }
+}
+
+module.exports = {
+ WEEKDAYS,
+ MONTHLY_NTH,
+ TIME_RE,
+ AT_RE,
+ MAX_OCCURRENCES,
+ DAY_MS,
+ wallPartsAt,
+ offsetMs,
+ instantsForWall,
+ resolveWall,
+ isRealDate,
+ nthWeekdayDay,
+ occurrencesBetween,
+ nextOccurrence,
+ describe,
+}
diff --git a/server/src/events/spec.js b/server/src/events/spec.js
index 7551d96..da888f8 100644
--- a/server/src/events/spec.js
+++ b/server/src/events/spec.js
@@ -12,18 +12,24 @@
// one that decides. A spec arriving by any other route (a restore, a fixture, a
// module shipping a definition as content) gets the same answer.
//
-// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys
-// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind:
-// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence
-// in an IANA zone and a spec that could name `weekly` before then would be a
-// schedule nothing honours. Unknown top-level keys are REFUSED rather than
-// preserved: a spec that silently carries `announcements` today is a spec whose
-// author believes announcements work, and the later phase that gives the key
-// meaning would inherit a corpus of unvalidated ones. The refusal list is the
-// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's
+// **What this file knows, and what it deliberately refuses.** Two top-level keys
+// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
+// than preserved: a spec that silently carries `announcements` today is a spec
+// whose author believes announcements work, and the later phase that gives the
+// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
+// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's
// `advance`, Phase 10 adds `announcements`.
+//
+// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
+// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
+// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
+// computed in is `event_definitions.timezone`, a sibling column this file cannot
+// see and does not need to: a well-formed wall clock resolves in every zone (a
+// DST gap shifts it, it is never rejected), so a schedule that validates here
+// computes there.
const registries = require('../modules/registries')
+const recurrence = require('./recurrence')
const { checkLiteral } = require('../engagement/conditions')
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
@@ -39,11 +45,20 @@ const MAX_PHASES = 40
const MAX_STEPS_PER_PHASE = 100
const MAX_STEPS = 500
-// The schedule shapes this phase understands. Phase 4 replaces this list with
-// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and
-// their timezone arithmetic. It is a list of one rather than an implicit default
-// so that the widening is a diff on this line.
-const SCHEDULE_KINDS = ['manual']
+// The four closed shapes of §E. `manual` is first because it is the default and
+// what an unscheduled draft carries; the other three are recurrences the runner
+// expands into occurrences ahead of time.
+const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
+
+// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
+// an `at` is an author who believes something about it that is not true — the
+// same argument the top-level refusal makes, one level down.
+const SCHEDULE_KEYS = {
+ manual: [],
+ once: ['at'],
+ weekly: ['days', 'time'],
+ monthly: ['nth', 'weekday', 'time'],
+}
// What a step does when its attempts are exhausted (§L). The disposition only —
// retry is not one of the values, it is what happens BEFORE one of them. Each
@@ -63,6 +78,86 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
/** The default disposition for an action whose risk class core knows. */
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
+/**
+ * Check one schedule shape and answer the normalised form of it.
+ *
+ * Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
+ * one — because `validate` collects every error and carries on, and a caller
+ * reading `spec.schedule.days` of a refused spec should find an empty recurrence
+ * rather than a half-built one.
+ *
+ * **`days` is normalised into week order**, not into the order they were typed.
+ * The spec is compared, described and diffed, and `['friday','monday']` and
+ * `['monday','friday']` naming the same schedule while differing as JSON is a
+ * version history that reports edits nobody made.
+ */
+function validateSchedule(kind, raw, errors) {
+ const at = (key) => `spec.schedule.${key}`
+
+ if (kind === 'once') {
+ const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
+ if (!m) {
+ errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
+ return { kind: 'manual' }
+ }
+ const [, y, mo, d, h, mi] = m.map(Number)
+ // The regex admits `2026-02-30`, which is a string and not a day.
+ if (!recurrence.isRealDate(y, mo, d)) {
+ errors.push(`${at('at')}: "${raw.at}" is not a real date`)
+ return { kind: 'manual' }
+ }
+ // Stored as the operator wrote it — a wall clock in the definition's own
+ // zone, never a UTC instant. §E: the schedule belongs to the event, and the
+ // instant is derived at materialisation.
+ const pad = (n) => String(n).padStart(2, '0')
+ return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
+ }
+
+ if (kind === 'weekly' || kind === 'monthly') {
+ const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
+ if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
+
+ if (kind === 'weekly') {
+ const rawDays = Array.isArray(raw.days) ? raw.days : null
+ if (!rawDays || rawDays.length === 0) {
+ errors.push(`${at('days')}: expected a non-empty array of weekday names`)
+ return { kind: 'manual' }
+ }
+ const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
+ if (unknown.length) {
+ errors.push(
+ `${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
+ )
+ }
+ const days = recurrence.WEEKDAYS.filter((name) =>
+ rawDays.some((d) => String(d).toLowerCase() === name),
+ )
+ if (!time || !days.length) return { kind: 'manual' }
+ return { kind: 'weekly', days, time }
+ }
+
+ const weekday = String(raw.weekday ?? '').toLowerCase()
+ if (!recurrence.WEEKDAYS.includes(weekday)) {
+ errors.push(
+ `${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
+ )
+ }
+ const nth = Number(raw.nth)
+ if (!recurrence.MONTHLY_NTH.includes(nth)) {
+ // -1 is "last", which a month with five Fridays makes different from 4.
+ // There is no 5: every month has a first through fourth of every weekday,
+ // so the closed set has no absent case (org lead, 2026-09-02).
+ errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
+ }
+ if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
+ return { kind: 'manual' }
+ }
+ return { kind: 'monthly', nth, weekday, time }
+ }
+
+ return { kind: 'manual' }
+}
+
/**
* Check one authored param object against an action's declared params.
*
@@ -136,18 +231,21 @@ function validate(raw, { knownActionIds = [] } = {}) {
}
// ── schedule ──
- const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
+ const rawSchedule =
+ raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
let schedule = { kind: 'manual' }
if (!isPlainObject(rawSchedule)) {
errors.push('spec.schedule: expected an object')
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
- errors.push(
- `spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`,
- )
+ errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
} else {
- const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind')
- if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`)
- schedule = { kind: rawSchedule.kind }
+ const kind = rawSchedule.kind
+ const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
+ const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
+ if (extra.length) {
+ errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
+ }
+ schedule = validateSchedule(kind, rawSchedule, errors)
}
// ── phases ──
diff --git a/server/src/model/events/eventCalendar.model.js b/server/src/model/events/eventCalendar.model.js
new file mode 100644
index 0000000..3dedc85
--- /dev/null
+++ b/server/src/model/events/eventCalendar.model.js
@@ -0,0 +1,170 @@
+// ── The calendar ───────────────────────────────────────────────────────────
+//
+// EVENTS.md §I: "month and list view, filtered by category, scope and series",
+// and Phase 4's stated deliverable — *the thing this feature exists to replace*
+// is a WordPress calendar plugin with no series field and no recurrence.
+//
+// **A calendar entry is one of two things, and the difference is not cosmetic.**
+//
+// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
+// pinned version and a console. Somebody can cancel it. It exists because the
+// runner materialised it inside its fourteen-day horizon, or because an admin
+// started it by hand.
+// - A **projection**: arithmetic. There is no row, nothing to cancel, and
+// nothing has been committed to. It exists so that a monthly event is visible
+// three weeks out instead of the calendar simply ending at the horizon (org
+// lead, 2026-09-02).
+//
+// The API says which each is and the UI renders them differently, because an
+// operator acting on a projection as though it were a booking is the failure
+// this distinction exists to prevent. A projection is a forecast of what the
+// runner *will* materialise, computed by the same `occurrencesBetween` the
+// runner itself calls — one arithmetic, so the forecast cannot disagree with
+// what later appears.
+//
+// **A projection is never emitted for an instant a run already occupies**, which
+// is what keeps the fortnight inside the horizon from showing everything twice.
+// That rule also does the right thing for a CANCELLED occurrence: the row is
+// still there, so nothing re-projects it, and an event an operator called off
+// does not reappear on the calendar as though it were still coming.
+
+const runsDb = require('./eventRuns.db')
+const definitionsDb = require('./eventDefinitions.db')
+const recurrence = require('../../events/recurrence')
+
+// A calendar request is operator-supplied, and a year-wide window across forty
+// weekly definitions is how a month view becomes an outage. Ninety-two days is
+// a three-month view — more than the month grid and the list either need.
+const MAX_WINDOW_DAYS = 92
+const MAX_ENTRIES = 1000
+
+const runEntry = (run) => ({
+ kind: 'run',
+ runId: run.id,
+ definitionId: run.definition_id,
+ title: run.definition_title,
+ slug: run.definition_slug,
+ seriesId: run.series_id || null,
+ seriesName: run.series_name || null,
+ seriesSlug: run.series_slug || null,
+ scheduledFor: run.scheduled_for,
+ timezone: run.timezone,
+ scope: run.scope,
+ status: run.status,
+ health: run.health,
+ version: run.version_number,
+ rehearsal: Boolean(run.rehearsal),
+ waitingSteps: Number(run.waiting_steps || 0),
+})
+
+const projectedEntry = (definition, occurrence) => ({
+ kind: 'projected',
+ runId: null,
+ definitionId: definition.id,
+ title: definition.title,
+ slug: definition.slug,
+ seriesId: definition.series_id || null,
+ seriesName: definition.series_name || null,
+ seriesSlug: definition.series_slug || null,
+ scheduledFor: occurrence.at,
+ timezone: definition.timezone,
+ scope: '',
+ status: null,
+ health: null,
+ // Why this instant is not the wall clock the schedule names. Carried on the
+ // projection as well as on the materialised run, so the calendar can explain
+ // a DST-shifted time before it happens rather than after.
+ adjusted: occurrence.adjusted,
+ shiftMinutes: occurrence.shiftMinutes,
+})
+
+/**
+ * The calendar for a window.
+ *
+ * `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
+ * projections interleaved. `horizon` is the instant past which nothing is
+ * materialised yet, so the UI can draw the line rather than infer it.
+ *
+ * **The instants are UTC and the placement is the client's.** A month grid has
+ * one date axis and the viewer's own zone is what "this month" means to the
+ * person reading it; each entry carries its own `timezone` so the time beside it
+ * reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
+ * their own. That is the split §E's "the timezone belongs to the event" implies:
+ * the event owns the time, the reader owns the calendar.
+ */
+async function calendar({
+ from,
+ to,
+ status = null,
+ scope = null,
+ seriesId = null,
+ horizonDays = 14,
+ now = new Date(),
+} = {}) {
+ const start = from instanceof Date ? from : new Date(from)
+ const end = to instanceof Date ? to : new Date(to)
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ return { ok: false, status: 400, errors: ['from and to must be dates'] }
+ }
+ if (end <= start) {
+ return { ok: false, status: 400, errors: ['to must be after from'] }
+ }
+ if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
+ return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
+ }
+
+ const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
+ const entries = runs.map(runEntry)
+
+ // Every instant a run already occupies, keyed by definition. Projections are
+ // per definition at the empty scope, so the definition and the instant are the
+ // whole key -- the same triple the unique index uses, with the scope fixed.
+ const taken = new Set(
+ runs
+ .filter((r) => !r.scope)
+ .map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
+ )
+
+ // A status filter is a filter on RUNS. A projection has no status, so asking
+ // for "everything that failed" must not answer with a forecast — it would be a
+ // forecast that failed, which is not a thing.
+ // The scope filter behaves the same way, and for the same reason: automatic
+ // expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
+ // to a named scope has no forecast to give.
+ if (!status && !scope) {
+ const definitions = await definitionsDb.findSchedulable()
+ for (const definition of definitions) {
+ if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
+ const schedule = definition.version_spec?.schedule
+ if (!schedule || schedule.kind === 'manual') continue
+ let occurrences = []
+ try {
+ occurrences = recurrence.occurrencesBetween(
+ schedule,
+ definition.timezone || 'UTC',
+ start,
+ end,
+ )
+ } catch {
+ continue
+ }
+ for (const occurrence of occurrences) {
+ if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
+ entries.push(projectedEntry(definition, occurrence))
+ }
+ }
+ }
+
+ entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
+
+ return {
+ ok: true,
+ status: 200,
+ window: { from: start, to: end },
+ horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
+ entries: entries.slice(0, MAX_ENTRIES),
+ truncated: entries.length > MAX_ENTRIES,
+ }
+}
+
+module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }
diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js
index 0794cfe..c237e79 100644
--- a/server/src/model/events/eventDefinitions.db.js
+++ b/server/src/model/events/eventDefinitions.db.js
@@ -114,6 +114,39 @@ const markReady = (id, versionId, userId) =>
[versionId, userId, id],
)
+/**
+ * Every definition the runner should expand a recurrence for (Phase 4).
+ *
+ * `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E
+ * defines `ready` as "a version has been published and the schedule is live", so
+ * publishing IS the switch and archiving is how an operator turns a recurrence
+ * off. A separate schedule-enabled flag would be a second answer to a question
+ * `state` already answers, and the two would eventually disagree.
+ *
+ * The VERSION's spec is joined rather than the definition's working copy: the
+ * draft is what an author is midway through editing, and a half-typed `weekly`
+ * must never materialise anything. The pinned spec comes back with it, so the
+ * whole expansion is one round trip.
+ *
+ * The series columns are here for the CALENDAR rather than the runner, which
+ * ignores them: a projected occurrence has to be filterable and labellable by
+ * its arc exactly as a materialised run is, and a second query to learn the name
+ * of a row this one already reached would be two round trips for a join.
+ */
+const findSchedulable = async () => {
+ const rows = await query(
+ `SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
+ d.current_version_id, d.series_id, v.spec AS version_spec,
+ s.name AS series_name, s.slug AS series_slug
+ FROM event_definitions d
+ JOIN event_versions v ON v.id = d.current_version_id
+ LEFT JOIN event_series s ON s.id = d.series_id
+ WHERE d.state = 'ready'
+ ORDER BY d.id`,
+ )
+ return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
+}
+
/**
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
@@ -127,6 +160,7 @@ module.exports = {
getById,
getBySlug,
slugTaken,
+ findSchedulable,
insert,
update,
markReady,
diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js
index be5008f..813c2ce 100644
--- a/server/src/model/events/eventDefinitions.model.js
+++ b/server/src/model/events/eventDefinitions.model.js
@@ -23,6 +23,7 @@
const db = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const runsDb = require('./eventRuns.db')
+const logDb = require('./eventRunLog.db')
const seriesDb = require('./eventSeries.db')
const spec = require('../../events/spec')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
@@ -229,7 +230,31 @@ async function publish(id, userId) {
const version = await versionsDb.nextVersion(id)
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
- return { ok: true, versionId, version, definition: await db.getById(id) }
+
+ // Occurrences already materialised ahead of their instant move to the new
+ // version; ones that have begun do not (org lead, 2026-09-02). Logged per run
+ // rather than only counted, because "which version did this run actually use"
+ // is the first question an audit asks and the pin is no longer immutable while
+ // a run is still `scheduled`.
+ const pending = await runsDb.listScheduledFor(id)
+ const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
+ const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
+ for (const run of stale) {
+ await logDb.write({
+ runId: run.id,
+ kind: 'run.status',
+ detail: {
+ to: 'scheduled',
+ repinned: true,
+ fromVersionId: run.version_id,
+ toVersionId: versionId,
+ toVersion: version,
+ by: userId,
+ },
+ })
+ }
+
+ return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
}
/**
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index 3c976f3..e537435 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -97,6 +97,92 @@ const materialise = async (run) => {
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
}
+/**
+ * Every run whose instant falls inside a window — the calendar's real half.
+ *
+ * Ascending, unlike the admin run list: a calendar is read forwards. The join
+ * reaches the series so a month can be filtered to one arc without a second
+ * round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
+ * a run records the zone it was COMPUTED in and a definition's zone can be
+ * edited afterwards.
+ */
+const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
+ const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
+ const args = [from, to]
+ if (status) {
+ where.push('r.status = ?')
+ args.push(status)
+ }
+ if (scope !== null && scope !== undefined) {
+ where.push('r.scope = ?')
+ args.push(scope)
+ }
+ if (seriesId) {
+ where.push('d.series_id = ?')
+ args.push(seriesId)
+ }
+ const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
+ const rows = await query(
+ `SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
+ d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
+ v.version AS version_number,
+ (SELECT COUNT(*) FROM event_run_steps s
+ WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
+ FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ JOIN event_versions v ON v.id = r.version_id
+ LEFT JOIN event_series se ON se.id = d.series_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY r.scheduled_for, r.id
+ LIMIT ${n}`,
+ args,
+ )
+ return rows.map(hydrate)
+}
+
+/**
+ * Point every not-yet-started occurrence of a definition at a new version.
+ *
+ * Publishing calls this, and the guard is the whole statement: `status =
+ * 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
+ * it pinned, for ever, because that pin is what makes it explicable afterwards
+ * -- and a run that has NOT begun has nothing to explain yet.
+ *
+ * **Why re-pinning is the right answer and doing nothing is not** (org lead,
+ * 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
+ * editor fixes a typo there are already fourteen days of rows carrying the old
+ * spec. Left alone, the fix reaches none of them, and the operator's only
+ * recourse -- cancelling each one -- is worse: a cancelled row still holds its
+ * slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
+ * version, it disappears.
+ *
+ * Answers how many were moved, so publish can say so rather than leaving it to
+ * be noticed.
+ */
+const repinScheduled = async (definitionId, versionId) => {
+ const result = await query(
+ `UPDATE event_runs
+ SET version_id = ?
+ WHERE definition_id = ?
+ AND status = 'scheduled'
+ AND started_at IS NULL
+ AND version_id <> ?`,
+ [versionId, definitionId, versionId],
+ )
+ return Number(result?.affectedRows || 0)
+}
+
+/** The scheduled, not-yet-started occurrences a re-pin would move. */
+const listScheduledFor = async (definitionId) =>
+ (
+ await query(
+ `SELECT id, version_id, scheduled_for FROM event_runs
+ WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
+ ORDER BY scheduled_for`,
+ [definitionId],
+ )
+ ).map(hydrate)
+
/** The occurrence the unique key names, whether or not this call created it. */
const findOccurrence = async (definitionId, scope, scheduledFor) => {
const [row] = await query(
@@ -370,6 +456,9 @@ module.exports = {
list,
getById,
materialise,
+ listInWindow,
+ repinScheduled,
+ listScheduledFor,
findOccurrence,
countActiveForDefinition,
findDue,
diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js
index 34294c5..f1dbd20 100644
--- a/server/src/model/events/eventRuns.model.js
+++ b/server/src/model/events/eventRuns.model.js
@@ -50,9 +50,21 @@ function renderConcurrencyKey(template, params) {
*
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
* the present, not a separate concept, which is what keeps the runner's one
- * materialise/advance path honest when Phase 4 adds recurrence on top.
+ * materialise/advance path honest now that Phase 4 has put recurrence on top.
+ *
+ * **Phase 4's expansion calls this, rather than a second insert path beside it.**
+ * That is deliberate: every check here — the definition is still `ready`, the
+ * version still has phases, the concurrency key renders, the first phase's steps
+ * are materialised with their idempotency keys — is one a scheduled occurrence
+ * needs at least as much as a hand-started one, because there is nobody watching
+ * when it happens. The `INSERT IGNORE` answering `created: false` is what makes
+ * it safe to call on every tick for every occurrence inside the horizon.
*/
-async function create(definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null } = {}, userId) {
+async function create(
+ definitionId,
+ { scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
+ userId,
+) {
const definition = await definitionsDb.getById(definitionId)
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
if (definition.state !== 'ready') {
@@ -107,6 +119,11 @@ async function create(definitionId, { scope = '', scheduledFor = null, rehearsal
version: version.version,
scope: scopeValue,
rehearsal: Boolean(rehearsal),
+ // 'manual' is an admin pressing start; 'schedule' is the runner expanding
+ // a recurrence (Phase 4). Both produce the same row, and the log is the
+ // only place the difference is recorded — `started_by` is NULL for both a
+ // scheduled occurrence and one started by a since-deleted account.
+ source,
by: userId,
},
})
diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js
index 2ed3729..17a6658 100644
--- a/server/src/model/events/eventSeries.db.js
+++ b/server/src/model/events/eventSeries.db.js
@@ -1,17 +1,31 @@
// ── event_series — SQL only ────────────────────────────────────────────────
//
-// EVENTS.md §D. The arc a definition may belong to. Phase 1 needs the reads —
-// `event_definitions.series_id` is a foreign key and the definition save path
-// has to check it resolves — and creating one is Phase 4's, where the calendar
-// is what makes an arc visible.
+// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
+// reads — `event_definitions.series_id` is a foreign key and the definition save
+// path has to check it resolves — and Phase 4 adds the writes, because the
+// calendar is what makes an arc visible and a form cannot offer a value nobody
+// can create.
+//
+// `ordering` here places a SERIES among the others on the calendar. A
+// definition's place WITHIN its arc is `event_definitions.series_order`, which
+// is the column an editor drags; the two are deliberately different columns on
+// different tables and the schema comment says so.
const { query } = require('../../utils/db')
-const list = async () =>
- query('SELECT * FROM event_series ORDER BY ordering, name, id')
+// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
+// the list is a handful of rows, and the delete path needs the same number to
+// tell an operator what they are about to detach.
+const SELECT_LIST = `
+ SELECT s.*,
+ (SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
+ FROM event_series s
+`
+
+const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
const getById = async (id) => {
- const [row] = await query('SELECT * FROM event_series WHERE id = ?', [id])
+ const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
return row || null
}
@@ -20,4 +34,40 @@ const exists = async (id) => {
return Boolean(row)
}
-module.exports = { list, getById, exists }
+/** Does any OTHER series hold this slug? The uniqueness pre-check. */
+const slugTaken = async (slug, exceptId = null) => {
+ const rows = exceptId
+ ? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
+ : await query('SELECT id FROM event_series WHERE slug = ?', [slug])
+ return rows.length > 0
+}
+
+const insert = async (s) => {
+ const result = await query(
+ `INSERT INTO event_series (name, slug, description, ordering, created_by)
+ VALUES (?, ?, ?, ?, ?)`,
+ [s.name, s.slug, s.description, s.ordering, s.created_by],
+ )
+ return Number(result.insertId)
+}
+
+const update = (id, s) =>
+ query(
+ `UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
+ [s.name, s.slug, s.description, s.ordering, id],
+ )
+
+/**
+ * A hard delete, and the one place in this feature that is one.
+ *
+ * A series is a label rather than authored content: nothing pins one, no run
+ * references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
+ * removing a series detaches its definitions and destroys nothing. That is why
+ * it is not archived the way a definition is — an archived label would be a
+ * state every calendar query has to remember for no benefit. The model answers
+ * with how many definitions were detached, so the operator learns what happened
+ * rather than discovering it on the calendar.
+ */
+const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
+
+module.exports = { list, getById, exists, slugTaken, insert, update, remove }
diff --git a/server/src/model/events/eventSeries.model.js b/server/src/model/events/eventSeries.model.js
new file mode 100644
index 0000000..04eb1e1
--- /dev/null
+++ b/server/src/model/events/eventSeries.model.js
@@ -0,0 +1,93 @@
+// ── Event series — the arc ─────────────────────────────────────────────────
+//
+// EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the
+// Void" is continuity that exists nowhere in the tooling this feature replaces
+// (§ "What the real calendar shows, and what it is missing": *no series or
+// recurrence field*). One small table buys it, and this is the policy half.
+//
+// **Why the writes are `admin, editor` and not `admin`.** A series is authoring,
+// and it is the same act as writing the definition that goes in it — §N2's
+// narrow gate is about *committing the deployment to a run* (publish, start),
+// which naming an arc does not do. An editor who can write the events but not
+// the arc they belong to would have to ask an admin to type a title.
+//
+// **A slug is derived once and then frozen**, exactly as a definition's is: the
+// public arc page lives at `/events/series/:slug` (Phase 14), and a slug that
+// moved would break every link to it. Renaming the series is free.
+
+const db = require('./eventSeries.db')
+const { slugify, uniqueSlug } = require('../teams/teamSlug')
+
+const MAX_NAME = 160
+const MAX_DESCRIPTION = 2000
+
+const trimOrNull = (v, max) => {
+ if (v === undefined || v === null) return null
+ const s = String(v).trim()
+ return s === '' ? null : s.slice(0, max)
+}
+
+const list = () => db.list()
+
+const getById = (id) => db.getById(id)
+
+async function validate(input, { existing = null } = {}) {
+ const errors = []
+ const body = input && typeof input === 'object' ? input : {}
+
+ const name = trimOrNull(body.name, MAX_NAME)
+ if (!name) errors.push('name is required')
+
+ const description = trimOrNull(body.description, MAX_DESCRIPTION)
+
+ const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering
+ const ordering = Number(orderingRaw)
+ if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) {
+ errors.push('ordering must be an integer 0..9999')
+ }
+
+ if (errors.length) return { ok: false, errors }
+ return { ok: true, series: { name, description, ordering } }
+}
+
+async function create(input, userId) {
+ const checked = await validate(input)
+ if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
+
+ // The taken set is read here rather than inside `uniqueSlug` because that
+ // helper is pure — the same shape the team and definition paths use.
+ const taken = (await db.list()).map((s) => s.slug)
+ const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' })
+
+ const id = await db.insert({ ...checked.series, slug, created_by: userId || null })
+ return { ok: true, status: 201, series: await db.getById(id) }
+}
+
+async function update(id, input, userId) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
+
+ const checked = await validate(input, { existing })
+ if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
+
+ // The slug is the existing one, deliberately: renaming a series must not move
+ // the address its arc page lives at.
+ await db.update(id, { ...checked.series, slug: existing.slug })
+ return { ok: true, status: 200, series: await db.getById(id) }
+}
+
+/**
+ * Delete a series, detaching whatever belonged to it.
+ *
+ * The count comes back so the caller can say *"3 events were detached"* rather
+ * than leaving an operator to notice on the calendar. `series_id` is
+ * `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown.
+ */
+async function remove(id) {
+ const existing = await db.getById(id)
+ if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
+ await db.remove(id)
+ return { ok: true, status: 200, detached: Number(existing.definition_count || 0) }
+}
+
+module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index a9b0900..a070cbe 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -23,6 +23,9 @@ const definitionsDb = require('../../../model/events/eventDefinitions.db')
const definitions = require('../../../model/events/eventDefinitions.model')
const versionsDb = require('../../../model/events/eventVersions.db')
const seriesDb = require('../../../model/events/eventSeries.db')
+const series = require('../../../model/events/eventSeries.model')
+const calendarModel = require('../../../model/events/eventCalendar.model')
+const eventRunner = require('../../../utils/eventRunner')
const runsDb = require('../../../model/events/eventRuns.db')
const runs = require('../../../model/events/eventRuns.model')
const controls = require('../../../model/events/eventRunControls.model')
@@ -152,17 +155,83 @@ exports.catalog = (_req, res) => {
})
}
+const shapeSeries = (s) => ({
+ id: s.id,
+ name: s.name,
+ slug: s.slug,
+ description: s.description,
+ ordering: s.ordering,
+ definitionCount: Number(s.definition_count || 0),
+})
+
/** GET /api/v1/admin/events/series */
exports.listSeries = async (_req, res) => {
const rows = await seriesDb.list()
+ res.json({ series: rows.map(shapeSeries) })
+}
+
+/** POST /api/v1/admin/events/series */
+exports.createSeries = async (req, res) => {
+ const result = await series.create(req.body, req.user?.id)
+ if (!result.ok) return res.status(result.status).json({ errors: result.errors })
+ await activity.log({
+ req,
+ action: 'event.series.created',
+ detail: { id: result.series.id, name: result.series.name },
+ })
+ res.status(201).json({ series: shapeSeries(result.series) })
+}
+
+/** PUT /api/v1/admin/events/series/:seriesId */
+exports.updateSeries = async (req, res) => {
+ const id = asId(req.params.seriesId)
+ if (!id) return res.status(404).json({ error: 'no such series' })
+ const result = await series.update(id, req.body, req.user?.id)
+ if (!result.ok) return res.status(result.status).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.series.updated', detail: { id, name: result.series.name } })
+ res.json({ series: shapeSeries(result.series) })
+}
+
+/**
+ * DELETE /api/v1/admin/events/series/:seriesId
+ *
+ * `detached` is in the response because the delete is not confined to the row:
+ * `series_id` is `ON DELETE SET NULL`, so definitions that belonged to the arc
+ * survive it without one. Saying how many is the difference between an operator
+ * knowing and an operator finding out.
+ */
+exports.deleteSeries = async (req, res) => {
+ const id = asId(req.params.seriesId)
+ if (!id) return res.status(404).json({ error: 'no such series' })
+ const result = await series.remove(id)
+ if (!result.ok) return res.status(result.status).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.series.deleted', detail: { id, detached: result.detached } })
+ res.json({ ok: true, detached: result.detached })
+}
+
+/**
+ * GET /api/v1/admin/events/calendar
+ *
+ * `from` and `to` are UTC instants and the caller supplies both: a month grid
+ * knows its own boundaries in the viewer's zone, and having the server guess
+ * them would be the server guessing the viewer's zone.
+ */
+exports.calendar = async (req, res) => {
+ const result = await calendarModel.calendar({
+ from: req.query.from,
+ to: req.query.to,
+ status: req.query.status || null,
+ scope: req.query.scope || null,
+ seriesId: asId(req.query.seriesId),
+ horizonDays: eventRunner.HORIZON_DAYS,
+ })
+ if (!result.ok) return res.status(result.status).json({ errors: result.errors })
res.json({
- series: rows.map((s) => ({
- id: s.id,
- name: s.name,
- slug: s.slug,
- description: s.description,
- ordering: s.ordering,
- })),
+ window: result.window,
+ horizon: result.horizon,
+ horizonDays: eventRunner.HORIZON_DAYS,
+ entries: result.entries,
+ truncated: result.truncated,
})
}
@@ -272,12 +341,16 @@ exports.publish = async (req, res) => {
await activity.log({
req,
action: 'event.definition.published',
- detail: { id, version: result.version, versionId: result.versionId },
+ detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
})
return res.json({
event: shapeDefinition(result.definition),
version: result.version,
versionId: result.versionId,
+ // How many already-materialised occurrences moved to this version. The
+ // screen says so, because "my fix did not reach next Friday" is otherwise
+ // found out on Friday.
+ repinned: result.repinned,
})
}
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 9c1360b..980cf62 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -18,8 +18,13 @@
// stubbed — there is no advance condition until Phase 5, no resource ledger
// until Phase 8 and no caps to price against until Phase 6.
//
-// **Literal paths are declared before `/:id`**, so `/catalog`, `/series` and
-// `/runs` are never read as an event id.
+// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
+// `/calendar` and `/runs` are never read as an event id.
+//
+// **Phase 4 added the series writes and the calendar.** The series writes are
+// `admin, editor` rather than `admin`: naming an arc is authoring, and §N2's
+// narrow gate is about committing the deployment to a run. The calendar is a
+// staff read like every other read here.
const express = require('express')
@@ -51,13 +56,74 @@ eventsRouter.get(
'/series',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'List the event series a definition may belong to'
- // #swagger.description = 'A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.'
+ // #swagger.description = 'A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "array", items: { type: "object", properties: { id: { type: "integer" }, name: { type: "string" }, slug: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.listSeries,
)
+eventsRouter.post(
+ '/series',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Create an event series'
+ // #swagger.description = 'Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
+ /* #swagger.responses[201] = { description: 'The created series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.createSeries,
+)
+
+eventsRouter.put(
+ '/series/:seriesId',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Rename or reorder an event series'
+ // #swagger.description = 'The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, description: { type: "string", nullable: true }, ordering: { type: "integer" } }, required: ["name"] } } } } */
+ /* #swagger.responses[200] = { description: 'The updated series', content: { "application/json": { schema: { type: "object", properties: { series: { type: "object", additionalProperties: true } } } } } } */
+ /* #swagger.responses[400] = { description: 'Validation failed', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.updateSeries,
+)
+
+eventsRouter.delete(
+ '/series/:seriesId',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Delete an event series, detaching whatever belonged to it'
+ // #swagger.description = 'A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Deleted; detached is how many definitions lost their series', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, detached: { type: "integer" } } } } } } */
+ /* #swagger.responses[404] = { description: 'No such series', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.deleteSeries,
+)
+
+// ── The calendar ────────────────────────────────────────────────────
+
+eventsRouter.get(
+ '/calendar',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'The calendar for a window: materialised runs and projected occurrences'
+ // #swagger.description = 'Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['from'] = { in: 'query', description: 'Window start, a UTC instant', required: true, schema: { type: 'string' } }
+ // #swagger.parameters['to'] = { in: 'query', description: 'Window end, a UTC instant. At most 92 days after from', required: true, schema: { type: 'string' } }
+ // #swagger.parameters['status'] = { in: 'query', description: 'Only runs in this status; suppresses projections', required: false, schema: { type: 'string' } }
+ // #swagger.parameters['scope'] = { in: 'query', description: 'Only runs at this scope; suppresses projections', required: false, schema: { type: 'string' } }
+ // #swagger.parameters['seriesId'] = { in: 'query', description: 'Only events belonging to this series', required: false, schema: { type: 'integer' } }
+ /* #swagger.responses[200] = { description: 'The window', content: { "application/json": { schema: { type: "object", properties: { window: { type: "object", additionalProperties: true }, horizon: { type: "string" }, horizonDays: { type: "integer" }, truncated: { type: "boolean" }, entries: { type: "array", items: { type: "object", properties: { kind: { type: "string" }, runId: { type: "integer", nullable: true }, definitionId: { type: "integer" }, title: { type: "string" }, slug: { type: "string" }, seriesName: { type: "string", nullable: true }, scheduledFor: { type: "string" }, timezone: { type: "string" }, scope: { type: "string" }, status: { type: "string", nullable: true }, health: { type: "string", nullable: true }, adjusted: { type: "string", nullable: true } } } } } } } } } */
+ /* #swagger.responses[400] = { description: 'The window is missing, inverted or wider than 92 days', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ controller.calendar,
+)
+
// ── Runs ──────────────────────────────────────────────────────────────────
//
// Declared ahead of /:id so the literal path is never read as a definition id.
@@ -265,9 +331,9 @@ eventsRouter.post(
'/:id/publish',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
- // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.'
+ // #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- /* #swagger.responses[200] = { description: 'The definition, now ready, and the version that was cut', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" } } } } } } */
+ /* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js
index 3c7bee7..9f6b4c1 100644
--- a/server/src/utils/eventRunner.js
+++ b/server/src/utils/eventRunner.js
@@ -12,14 +12,22 @@
// 3. **advance** — claim each due run and move it through its phases
// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
//
-// **What "materialise" means in this phase.** §E's tick materialises due
-// occurrences from a recurrence; the spec validator accepts `kind: 'manual'`
-// alone until Phase 4, so there is no recurrence to expand and the only
-// occurrences that exist are the ones an admin created. What that leaves for this
-// leg is the half that is already real and already needed: the grace window. A
-// run whose instant passed while the process was down does not start late and
-// silently — it becomes `missed`, which is terminal and which a human can see
-// (§L). Phase 4 adds the expansion above it.
+// **What "materialise" means, and why it is two halves.** Phase 4 completed it.
+// The first half EXPANDS: every `ready` definition's recurrence is computed in
+// its own IANA zone and every occurrence inside a fourteen-day horizon becomes a
+// real `scheduled` row (`INSERT IGNORE` against the occurrence key, so the tick
+// that already made one makes nothing). The second half SWEEPS: a run whose
+// instant passed while the process was down does not start late and silently —
+// it becomes `missed`, which is terminal and which a human can see (§L).
+//
+// **The two halves need each other, and the horizon is why.** Expansion looks
+// forward from `now - grace` only, so an occurrence nobody ever materialised is
+// never invented retroactively — waking up after three days down must not
+// manufacture three days of history that no operator could have seen or
+// cancelled. It does not have to: because rows exist a fortnight ahead of their
+// instant, an outage that spans an occurrence finds the row already there, and
+// the sweep marks it `missed` honestly. The horizon is what makes the missed
+// sweep mean anything for a recurring event.
//
// **Two properties this file must not lose**, both already paid for once on this
// codebase:
@@ -45,6 +53,9 @@ const runsDb = require('../model/events/eventRuns.db')
const stepsDb = require('../model/events/eventRunSteps.db')
const logDb = require('../model/events/eventRunLog.db')
const versionsDb = require('../model/events/eventVersions.db')
+const definitionsDb = require('../model/events/eventDefinitions.db')
+const runsModel = require('../model/events/eventRuns.model')
+const recurrence = require('../events/recurrence')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
const log = require('./logger')('event-runner')
@@ -59,6 +70,19 @@ const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
const RUN_BATCH = Number(process.env.EVENT_RUN_BATCH) || 50
const STEPS_PER_TICK = Number(process.env.EVENT_STEPS_PER_TICK) || 25
+// How far ahead a recurrence is turned into real rows (org lead, 2026-09-02).
+// Fourteen days is a fortnight of occurrences an operator can see, cancel and
+// reschedule ONE AT A TIME, which a projection is not — and it is short enough
+// that a definition edited today affects almost everything still ahead of it.
+// Beyond it the calendar projects rather than materialises, so a monthly event
+// is still visible three weeks out without a row nobody will honour.
+const HORIZON_DAYS = Number(process.env.EVENT_MATERIALISE_AHEAD_DAYS) || 14
+
+// A bound on one definition's expansion in one tick, not a target. A daily
+// schedule over a fortnight is fourteen; this is what stops a hand-written spec
+// turning one tick into a thousand inserts.
+const MAX_OCCURRENCES_PER_DEFINITION = 100
+
// A step's retries (org lead, 2026-09-02). §L specifies `retry(n) -> skip |
// pause | abort_run` and names no `n`; it lives here, as one number an operator
// can change, rather than in a column no authoring surface would ever show.
@@ -397,6 +421,98 @@ async function processRun(run, now = new Date()) {
}
/** Occurrences that passed their own grace window while nothing was running (§L). */
+/**
+ * Turn every `ready` definition's recurrence into rows inside the horizon.
+ *
+ * The first half of the materialise leg (§E). Answers how many occurrences were
+ * newly created, which is zero on almost every tick — the horizon moves fifteen
+ * seconds at a time, so a weekly event creates one row a week and answers
+ * `created: false` for the same fourteen occurrences in between.
+ *
+ * **The window starts at `now - grace`, not at `now`.** An occurrence whose
+ * instant just passed is still startable inside the definition's own grace
+ * window, and that is exactly the case of a definition published four minutes
+ * before its first occurrence. An occurrence older than that is not materialised
+ * at all rather than materialised-then-swept: a row nobody could ever have seen
+ * is not history, and writing one would put a `missed` event on the calendar for
+ * a date on which this deployment had no such event.
+ *
+ * **Expansion is per definition and one failure does not stop the sweep.** A
+ * spec written directly into the database with a shape the validator would have
+ * refused is a bad row, not a bad tick.
+ */
+async function expandSchedules(now) {
+ const definitions = await definitionsDb.findSchedulable()
+ let created = 0
+
+ for (const definition of definitions) {
+ const schedule = definition.version_spec?.schedule
+ if (!schedule || schedule.kind === 'manual') continue
+
+ const from = now.getTime() - Number(definition.grace_seconds || 0) * 1000
+ const to = now.getTime() + HORIZON_DAYS * recurrence.DAY_MS
+
+ let occurrences
+ try {
+ occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', from, to, {
+ limit: MAX_OCCURRENCES_PER_DEFINITION,
+ })
+ } catch (err) {
+ log.error('could not expand a schedule', {
+ definition: definition.id,
+ timezone: definition.timezone,
+ message: err.message,
+ })
+ continue
+ }
+
+ for (const occurrence of occurrences) {
+ try {
+ // `runsModel.create` rather than a second insert path: it re-checks that
+ // the definition is still `ready` and the version still has phases, and
+ // it materialises the first phase's steps with their idempotency keys.
+ // Nobody is watching a scheduled occurrence, so it needs those checks
+ // more than a hand-started one does.
+ const result = await runsModel.create(
+ definition.id,
+ // Scope is empty, deliberately (org lead, 2026-09-02). A fan-out across
+ // named scopes needs a registry of what a scope IS, which no phase owns
+ // yet; inventing one here would be a contract the modules were never
+ // asked about. An admin's own start route still takes any scope.
+ { scope: '', scheduledFor: occurrence.at, source: 'schedule' },
+ null,
+ )
+ if (!result.ok || !result.created) continue
+ created += 1
+ if (occurrence.adjusted) {
+ // Why the clock reads oddly, recorded where an operator will look for
+ // it rather than left to be rediscovered at 3am on the last Sunday in
+ // October.
+ await logDb.write({
+ runId: result.run.id,
+ kind: 'run.created',
+ detail: {
+ dstAdjusted: occurrence.adjusted,
+ shiftMinutes: occurrence.shiftMinutes,
+ timezone: definition.timezone,
+ scheduledFor: occurrence.at,
+ },
+ })
+ }
+ } catch (err) {
+ log.error('could not materialise an occurrence', {
+ definition: definition.id,
+ at: occurrence.at,
+ message: err.message,
+ })
+ }
+ }
+ }
+
+ if (created) log.info('occurrences materialised', { created, horizonDays: HORIZON_DAYS })
+ return created
+}
+
async function sweepMissed(now) {
const missed = await runsDb.findMissed(now)
let n = 0
@@ -433,6 +549,12 @@ async function tick(now = new Date()) {
log.error('failed to reclaim stale claims', { message: err.message })
}
+ try {
+ await expandSchedules(now)
+ } catch (err) {
+ log.error('schedule expansion failed', { message: err.message })
+ }
+
try {
await sweepMissed(now)
} catch (err) {
@@ -507,11 +629,13 @@ module.exports = {
advanceRun,
drainStep,
sweepMissed,
+ expandSchedules,
prune,
OWNER,
POLL_MS,
MAX_ATTEMPTS,
RETRY_MS,
+ HORIZON_DAYS,
RUN_LEASE_MS,
LOG_RETENTION_DAYS,
RUN_TERMINAL,
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 8d7a039..10ba6ba 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3534,6 +3534,174 @@
}
}
},
+ "/api/v1/admin/events/calendar": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "The calendar for a window: materialised runs and projected occurrences",
+ "description": "Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.",
+ "parameters": [
+ {
+ "name": "from",
+ "in": "query",
+ "description": "Window start, a UTC instant",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "to",
+ "in": "query",
+ "description": "Window end, a UTC instant. At most 92 days after from",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Only runs in this status; suppresses projections",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "scope",
+ "in": "query",
+ "description": "Only runs at this scope; suppresses projections",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "seriesId",
+ "in": "query",
+ "description": "Only events belonging to this series",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The window",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "window": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "horizon": {
+ "type": "string"
+ },
+ "horizonDays": {
+ "type": "integer"
+ },
+ "truncated": {
+ "type": "boolean"
+ },
+ "entries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "kind": {
+ "type": "string"
+ },
+ "runId": {
+ "type": "integer",
+ "nullable": true
+ },
+ "definitionId": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "string"
+ },
+ "slug": {
+ "type": "string"
+ },
+ "seriesName": {
+ "type": "string",
+ "nullable": true
+ },
+ "scheduledFor": {
+ "type": "string"
+ },
+ "timezone": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "nullable": true
+ },
+ "health": {
+ "type": "string",
+ "nullable": true
+ },
+ "adjusted": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The window is missing, inverted or wider than 92 days",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/events/catalog": {
"get": {
"tags": [
@@ -4431,7 +4599,7 @@
"Admin · Events"
],
"summary": "List the event series a definition may belong to",
- "description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.",
+ "description": "A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.",
"responses": {
"200": {
"description": "The series",
@@ -4488,6 +4656,265 @@
"bearerAuth": []
}
]
+ },
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Create an event series",
+ "description": "Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.",
+ "responses": {
+ "201": {
+ "description": "The created series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "series": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "ordering": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/events/series/{seriesId}": {
+ "put": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Rename or reorder an event series",
+ "description": "The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.",
+ "parameters": [
+ {
+ "name": "seriesId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The updated series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "series": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "ordering": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "name"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Delete an event series, detaching whatever belonged to it",
+ "description": "A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.",
+ "parameters": [
+ {
+ "name": "seriesId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Deleted; detached is how many definitions lost their series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "detached": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such series",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
}
},
"/api/v1/admin/events/{id}": {
@@ -4755,7 +5182,7 @@
"Admin · Events"
],
"summary": "Snapshot the working spec into an immutable version and mark the definition ready",
- "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.",
+ "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.",
"parameters": [
{
"name": "id",
@@ -4768,7 +5195,7 @@
],
"responses": {
"200": {
- "description": "The definition, now ready, and the version that was cut",
+ "description": "The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it",
"content": {
"application/json": {
"schema": {
@@ -4783,6 +5210,9 @@
},
"versionId": {
"type": "integer"
+ },
+ "repinned": {
+ "type": "integer"
}
}
}
diff --git a/server/test/eventRecurrence.test.js b/server/test/eventRecurrence.test.js
new file mode 100644
index 0000000..9c88b13
--- /dev/null
+++ b/server/test/eventRecurrence.test.js
@@ -0,0 +1,273 @@
+// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ──────────────────────────
+//
+// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is
+// them. The reason it is worth a test file of its own is that every failure here
+// is silent in production: an event computed an hour off, or dropped for one
+// week a year, looks exactly like an event that happened correctly until an
+// operator is standing in the wrong place at the wrong time.
+//
+// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert
+// against the real transitions rather than against a hand-written offset table:
+//
+// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not
+// exist. A weekly 02:30 event is the case the org lead decided.
+// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00
+// happens twice.
+// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is
+// what catches an implementation that assumed whole hours.
+// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one
+// that assumed the gap is always an hour.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const r = require('../src/events/recurrence')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+/** What an instant reads as on the wall in a zone — the assertion that matters. */
+const wall = (zone, at) => {
+ const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at)
+ const pad = (n) => String(n).padStart(2, '0')
+ return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}`
+}
+
+const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at))
+
+// ── The rule the whole feature rests on ────────────────────────────────────
+
+test('a weekly event keeps its LOCAL time across a DST boundary', () => {
+ // The single most important assertion in this file. Friday 20:00 in Berlin is
+ // 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on
+ // every one of those Fridays. A recurrence computed in UTC would put half the
+ // year an hour out, which is exactly what §E forbids.
+ const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
+ const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
+
+ assert.deepEqual(walls(found, 'Europe/Berlin'), [
+ '2026-03-20 20:00',
+ '2026-03-27 20:00',
+ '2026-04-03 20:00',
+ '2026-04-10 20:00',
+ ])
+ // And the UTC instants really did move, which is what proves the zone was
+ // consulted rather than the arithmetic accidentally agreeing.
+ assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z')
+ assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z')
+})
+
+test('a weekly event keeps its local time across the October transition too', () => {
+ const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
+ const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7))
+ assert.deepEqual(walls(found, 'Europe/Berlin'), [
+ '2026-10-23 20:00',
+ '2026-10-30 20:00',
+ '2026-11-06 20:00',
+ ])
+ assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z')
+ assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z')
+})
+
+// ── The two DST rules, as decided ──────────────────────────────────────────
+
+test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => {
+ // 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The
+ // decision is the first valid instant — 03:00 — rather than "shift by the gap"
+ // (03:30): the event happens as close to the authored time as the calendar
+ // allows.
+ const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30)
+ assert.equal(resolved.adjusted, 'gap')
+ assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00')
+ assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z')
+ assert.equal(resolved.shiftMinutes, 30)
+})
+
+test('a local time that happens twice takes the FIRST of them', () => {
+ // 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again
+ // at 01:30Z (+1, now CET). The first is the answer, and the second must not be
+ // — an event that fired at the later one would be an hour late by the clock
+ // the author wrote it against.
+ const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30)
+ assert.equal(resolved.adjusted, 'ambiguous')
+ assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z')
+ assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30')
+
+ // Both instants really do read 02:30 — the fixture is only meaningful if the
+ // ambiguity is real.
+ const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30))
+ assert.equal(both.length, 2)
+ assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30))
+ assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30))
+})
+
+test('a weekly event in the gap still happens that week — it is never dropped', () => {
+ // The rule that makes the gap decision worth having. A Sunday 02:30 event in
+ // Berlin happens on 29 March like every other Sunday; it simply happens at
+ // 03:00.
+ const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' }
+ const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6))
+ assert.deepEqual(walls(found, 'Europe/Berlin'), [
+ '2026-03-22 02:30',
+ '2026-03-29 03:00',
+ '2026-04-05 02:30',
+ ])
+ assert.equal(found[1].adjusted, 'gap')
+ assert.equal(found[0].adjusted, null)
+})
+
+test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => {
+ // Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no
+ // 02:15 local. An implementation that assumed a whole-hour gap gets this wrong.
+ const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15)
+ assert.equal(resolved.adjusted, 'gap')
+ assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30')
+})
+
+test('a zone with no DST at all is left completely alone', () => {
+ // Asia/Kolkata is +05:30 all year, and the half hour is the point: an
+ // implementation carrying whole-hour offsets around would be 30 minutes out
+ // here, every day, without any transition to blame.
+ const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' }
+ const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
+ assert.equal(found.length, 4)
+ for (const o of found) {
+ assert.equal(o.adjusted, null)
+ assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30')
+ assert.equal(o.at.toISOString().slice(11, 16), '14:00')
+ }
+})
+
+// ── The shapes ─────────────────────────────────────────────────────────────
+
+test('`once` produces its single occurrence, and only inside the window', () => {
+ const schedule = { kind: 'once', at: '2026-10-31T20:00' }
+ const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1))
+ assert.equal(inside.length, 1)
+ assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00')
+
+ const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1))
+ assert.deepEqual(outside, [])
+})
+
+test('`weekly` honours every named day, in week order', () => {
+ const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' }
+ const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15))
+ assert.deepEqual(walls(found, 'UTC'), [
+ '2026-06-03 18:00',
+ '2026-06-06 18:00',
+ '2026-06-10 18:00',
+ '2026-06-13 18:00',
+ ])
+})
+
+test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => {
+ // The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five;
+ // in those months "last" and "fourth" are different days, and a fishing contest
+ // on the last Friday is exactly that shape.
+ const last = r.occurrencesBetween(
+ { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' },
+ 'UTC',
+ Date.UTC(2026, 4, 1),
+ Date.UTC(2026, 8, 1),
+ )
+ const fourth = r.occurrencesBetween(
+ { kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' },
+ 'UTC',
+ Date.UTC(2026, 4, 1),
+ Date.UTC(2026, 8, 1),
+ )
+ // August 2026 has four Fridays, so "last" and "fourth" agree there and
+ // disagree in May and July. That the two lists share a member is the point:
+ // -1 is not a synonym for 4, and it is not a synonym for "different" either.
+ assert.deepEqual(walls(last, 'UTC'), [
+ '2026-05-29 19:00',
+ '2026-06-26 19:00',
+ '2026-07-31 19:00',
+ '2026-08-28 19:00',
+ ])
+ assert.deepEqual(walls(fourth, 'UTC'), [
+ '2026-05-22 19:00',
+ '2026-06-26 19:00',
+ '2026-07-24 19:00',
+ '2026-08-28 19:00',
+ ])
+ assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC'))
+})
+
+test('every month has a first through fourth of every weekday', () => {
+ // The claim the closed set rests on: because there is no `nth: 5`, there is no
+ // absent-occurrence case to define. Checked across three years rather than
+ // asserted in a comment.
+ for (let year = 2026; year <= 2028; year += 1) {
+ for (let month = 1; month <= 12; month += 1) {
+ for (let weekday = 0; weekday <= 6; weekday += 1) {
+ for (const nth of [1, 2, 3, 4, -1]) {
+ const day = r.nthWeekdayDay(year, month, weekday, nth)
+ assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`)
+ }
+ }
+ }
+ }
+})
+
+test('`manual` is not a recurrence and expands to nothing', () => {
+ assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), [])
+})
+
+// ── Bounds and refusals ────────────────────────────────────────────────────
+
+test('an inverted or empty window answers with nothing rather than throwing', () => {
+ const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
+ assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), [])
+ assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), [])
+})
+
+test('a malformed schedule expands to nothing rather than to a wrong instant', () => {
+ // These shapes cannot come through `spec.js`, but they can come from a row
+ // written directly into the database — and the runner must not turn one into a
+ // world change at an invented time.
+ assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), [])
+ assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), [])
+})
+
+test('the expansion is bounded, so a wide window cannot become an outage', () => {
+ const schedule = {
+ kind: 'weekly',
+ days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
+ time: '12:00',
+ }
+ const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1))
+ assert.equal(found.length, r.MAX_OCCURRENCES)
+
+ const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 })
+ assert.equal(smaller.length, 10)
+})
+
+test('nextOccurrence looks forward and finds nothing when there is nothing', () => {
+ const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1))
+ assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00')
+
+ // A `once` already in the past has no next occurrence, which is what stops a
+ // one-off event being re-materialised for ever.
+ assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null)
+ assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null)
+})
+
+test('describe says the schedule back in words, in the event own zone', () => {
+ assert.equal(
+ r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
+ 'Every Friday and Saturday at 20:00 (Europe/Berlin)',
+ )
+ assert.equal(
+ r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
+ 'The last Friday of every month at 19:30 (Asia/Kolkata)',
+ )
+ assert.equal(r.describe({ kind: 'manual' }), 'Started by hand')
+})
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
index 281cdd7..e9d5dbb 100644
--- a/server/test/eventRunner.test.js
+++ b/server/test/eventRunner.test.js
@@ -35,6 +35,7 @@ const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const versionsDb = require('../src/model/events/eventVersions.db')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
const db = require('../src/utils/db')
after(() => db.close())
@@ -47,7 +48,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
let store
const originals = {}
-for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) {
+for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb]]) {
originals[name] = { mod, fns: { ...mod } }
}
@@ -68,6 +69,14 @@ function installStubs() {
nextStepId: 1,
}
+ // Phase 4 put a schedule-expansion leg in front of the tick. This file is
+ // about what the runner does with runs that ALREADY exist, so it has nothing
+ // to expand — but the leg is a real query, and left unstubbed every `tick()`
+ // here would reach for the dead-port pool and wait on it. Answering with an
+ // empty list is what keeps this file measuring the runner rather than a
+ // connection timeout.
+ Object.assign(definitionsDb, { findSchedulable: async () => [] })
+
// Snapshots, not live references. A SQL SELECT hands back a copy, and the
// runner reads `step.attempts` as the value BEFORE its own claim incremented
// it — returning references here would make the retry budget off by one in the
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
index 51bdda3..01d4760 100644
--- a/server/test/eventRunnerSql.test.js
+++ b/server/test/eventRunnerSql.test.js
@@ -38,6 +38,22 @@
// unsettled seq instead, which is a different step whenever a phase carried
// on past an `on_failure: skip` failure.
//
+// **Phase 4 added two reads**, and a read earns a place here when a stub cannot
+// tell it is wrong:
+//
+// * **`findSchedulable`** - the query the runner runs on EVERY tick to decide
+// what has a recurrence to expand. It joins a definition to its published
+// version and left-joins the series, and every stub of it in
+// `eventSchedule.test.js` is a hand-written object rather than that join. A
+// syntax error or a wrong join direction here is a runner that materialises
+// nothing, silently, for ever.
+// * **`repinScheduled`** - the UPDATE publish runs over already-materialised
+// occurrences. Its guard is the whole statement, and the two rows it must
+// NOT touch are a run that has started and a run that is already terminal.
+// * **`listInWindow`** - the calendar's real half, with a correlated subquery
+// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
+// series.
+//
// Plus the two unique indexes that are load-bearing rather than tidy:
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
@@ -61,10 +77,29 @@ const mariadb = require('mariadb')
// Trimmed to the columns these statements read or write. The ENUMs are verbatim,
// because "is `missed` a legal value" is one of the things being proved.
const SCHEMA = `
+CREATE TABLE event_series (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(160) NOT NULL,
+ slug VARCHAR(160) NOT NULL,
+ ordering INT NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(200) NOT NULL DEFAULT 'x',
+ slug VARCHAR(200) NOT NULL DEFAULT 'x',
+ state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
+ current_version_id INT NULL,
+ series_id INT NULL,
+ concurrency_key VARCHAR(190) NULL,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
grace_seconds INT NOT NULL DEFAULT 900
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE event_versions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ definition_id INT NOT NULL,
+ version INT NOT NULL DEFAULT 1,
+ spec JSON NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
@@ -207,6 +242,41 @@ SELECT r.id FROM event_runs r
WHERE r.status = 'scheduled'
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?`
+// Verbatim `eventDefinitions.db#findSchedulable`.
+const FIND_SCHEDULABLE = `
+SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
+ d.current_version_id, d.series_id, v.spec AS version_spec,
+ s.name AS series_name, s.slug AS series_slug
+ FROM event_definitions d
+ JOIN event_versions v ON v.id = d.current_version_id
+ LEFT JOIN event_series s ON s.id = d.series_id
+ WHERE d.state = 'ready'
+ ORDER BY d.id`
+
+// Verbatim `eventRuns.db#listInWindow`, with no optional filter applied.
+const LIST_IN_WINDOW = `
+SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
+ d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
+ v.version AS version_number,
+ (SELECT COUNT(*) FROM event_run_steps s
+ WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
+ FROM event_runs r
+ JOIN event_definitions d ON d.id = r.definition_id
+ JOIN event_versions v ON v.id = r.version_id
+ LEFT JOIN event_series se ON se.id = d.series_id
+ WHERE r.scheduled_for >= ? AND r.scheduled_for < ?
+ ORDER BY r.scheduled_for, r.id
+ LIMIT 500`
+
+// Verbatim `eventRuns.db#repinScheduled`.
+const REPIN_SCHEDULED = `
+UPDATE event_runs
+ SET version_id = ?
+ WHERE definition_id = ?
+ AND status = 'scheduled'
+ AND started_at IS NULL
+ AND version_id <> ?`
+
const DB = `rg_events_test_${process.pid}`
let pool = null
let available = false
@@ -274,6 +344,8 @@ beforeEach(async () => {
await pool.query('DELETE FROM event_run_steps')
await pool.query('DELETE FROM event_runs')
await pool.query('DELETE FROM event_definitions')
+ await pool.query('DELETE FROM event_versions')
+ await pool.query('DELETE FROM event_series')
})
async function seedRun(over = {}) {
@@ -659,3 +731,163 @@ test('a guarded transition refuses a run that was cancelled underneath it', asyn
assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
assert.equal((await runById(runId)).status, 'cancelled')
})
+
+
+// ── Phase 4: the two reads ────────────────────────────────────────
+
+/** A definition with a published version, and optionally a series. */
+async function seedDefinition({ state = 'ready', spec = { schedule: { kind: 'manual' } }, series = null } = {}) {
+ let seriesId = null
+ if (series) {
+ const s = await pool.query('INSERT INTO event_series (name, slug) VALUES (?, ?)', [series, series])
+ seriesId = s.insertId
+ }
+ const d = await pool.query(
+ 'INSERT INTO event_definitions (state, series_id, timezone) VALUES (?, ?, ?)',
+ [state, seriesId, 'Europe/Berlin'],
+ )
+ const v = await pool.query(
+ 'INSERT INTO event_versions (definition_id, version, spec) VALUES (?, 1, ?)',
+ [d.insertId, JSON.stringify(spec)],
+ )
+ await pool.query('UPDATE event_definitions SET current_version_id = ? WHERE id = ?', [
+ v.insertId,
+ d.insertId,
+ ])
+ return { definitionId: d.insertId, versionId: v.insertId, seriesId }
+}
+
+test('findSchedulable returns ready definitions with their PUBLISHED spec, series or not', async (t) => {
+ if (needDb(t)) return
+ const withSeries = await seedDefinition({
+ spec: { schedule: { kind: 'weekly', days: ['friday'], time: '20:00' } },
+ series: 'royal-spy',
+ })
+ const withoutSeries = await seedDefinition({ spec: { schedule: { kind: 'manual' } } })
+
+ const found = await pool.query(FIND_SCHEDULABLE)
+ const ids = found.map((r) => r.id).sort((a, b) => a - b)
+ assert.deepEqual(ids, [withSeries.definitionId, withoutSeries.definitionId].sort((a, b) => a - b))
+
+ // The LEFT JOIN must not drop the definition that belongs to no arc — an
+ // inner join here would make every event outside a series unschedulable, and
+ // most events are outside one.
+ const plain = found.find((r) => r.id === withoutSeries.definitionId)
+ assert.equal(plain.series_name, null)
+
+ const arced = found.find((r) => r.id === withSeries.definitionId)
+ assert.equal(arced.series_name, 'royal-spy')
+ assert.equal(arced.timezone, 'Europe/Berlin')
+
+ // The spec really came back, and really came back parseable.
+ const spec = typeof arced.version_spec === 'string' ? JSON.parse(arced.version_spec) : arced.version_spec
+ assert.equal(spec.schedule.kind, 'weekly')
+})
+
+test('findSchedulable skips a draft, an archived one, and one with no published version', async (t) => {
+ if (needDb(t)) return
+ await seedDefinition({ state: 'draft' })
+ await seedDefinition({ state: 'archived' })
+ // `ready` with a dangling version pointer: the JOIN is what must drop it, and
+ // a definition whose version row went missing must not become a runner crash.
+ const orphan = await seedDefinition({ state: 'ready' })
+ await pool.query('DELETE FROM event_versions WHERE id = ?', [orphan.versionId])
+
+ assert.equal((await pool.query(FIND_SCHEDULABLE)).length, 0)
+})
+
+test('listInWindow is half-open on the window, and counts only PARKED steps as waiting', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition()
+ const at = async (when) => {
+ const r = await pool.query(
+ 'INSERT INTO event_runs (definition_id, version_id, scope, scheduled_for) VALUES (?, ?, ?, ?)',
+ [def.definitionId, def.versionId, '', when],
+ )
+ return r.insertId
+ }
+ const before = await at(new Date('2026-09-01T00:00:00Z'))
+ const onFrom = await at(new Date('2026-09-02T00:00:00Z'))
+ const inside = await at(new Date('2026-09-05T00:00:00Z'))
+ const onTo = await at(new Date('2026-09-09T00:00:00Z'))
+
+ // `>= from AND < to` — the instant ON the upper bound belongs to the NEXT
+ // window. A closed interval would draw the last day of one month and the first
+ // of the next as the same occurrence twice.
+ const found = await pool.query(LIST_IN_WINDOW, [
+ new Date('2026-09-02T00:00:00Z'),
+ new Date('2026-09-09T00:00:00Z'),
+ ])
+ assert.deepEqual(found.map((r) => r.id), [onFrom, inside])
+ assert.ok(!found.some((r) => r.id === before || r.id === onTo))
+
+ // A parked step is `running` with a NULL lease; a leased one is the runner
+ // mid-dispatch and is not waiting on anybody.
+ await seedStep(inside, { status: 'running', claimExpiresAt: null, key: 'a'.repeat(40) })
+ await seedStep(inside, {
+ seq: 1,
+ status: 'running',
+ claimedBy: 'host',
+ claimExpiresAt: later(60_000),
+ key: 'b'.repeat(40),
+ })
+ const again = await pool.query(LIST_IN_WINDOW, [
+ new Date('2026-09-02T00:00:00Z'),
+ new Date('2026-09-09T00:00:00Z'),
+ ])
+ assert.equal(Number(again.find((r) => r.id === inside).waiting_steps), 1)
+})
+
+
+test('repinScheduled moves the occurrences that have not begun, and only those', async (t) => {
+ if (needDb(t)) return
+ // The case: an editor fixes a typo on a weekly event on Wednesday. Two Fridays
+ // are already materialised on v3, last Friday's run is finished, and one is in
+ // flight right now.
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
+ const mk = async (status, versionId, startedAt, when) =>
+ (
+ await pool.query(
+ `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [def.insertId, versionId, `s${when}`, status, T0, startedAt],
+ )
+ ).insertId
+
+ const ahead1 = await mk('scheduled', 3, null, 1)
+ const ahead2 = await mk('scheduled', 3, null, 2)
+ const running = await mk('running', 3, T0, 3)
+ const done = await mk('completed', 3, T0, 4)
+ // Already on the new version: excluded by `version_id <> ?`, so a second
+ // publish of an unchanged definition is not a fleet of pointless writes.
+ const already = await mk('scheduled', 4, null, 5)
+
+ const moved = rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4]))
+ assert.equal(moved, 2)
+
+ const versionOf = async (id) =>
+ Number((await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [id]))[0].version_id)
+ assert.equal(await versionOf(ahead1), 4)
+ assert.equal(await versionOf(ahead2), 4)
+ // A run that has begun keeps the version it pinned, for ever: that pin is what
+ // makes it explicable afterwards.
+ assert.equal(await versionOf(running), 3)
+ assert.equal(await versionOf(done), 3)
+ assert.equal(await versionOf(already), 4)
+})
+
+test('a scheduled run whose started_at is somehow set is left alone', async (t) => {
+ if (needDb(t)) return
+ // Belt and braces on the guard: `status = 'scheduled'` and `started_at IS NULL`
+ // are two conditions rather than one because a row that has both is the only
+ // row that is provably untouched.
+ const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
+ const r = await pool.query(
+ `INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
+ VALUES (?, 3, '', 'scheduled', ?, ?)`,
+ [def.insertId, T0, T0],
+ )
+ assert.equal(rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4])), 0)
+ const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
+ assert.equal(Number(after.version_id), 3)
+})
diff --git a/server/test/eventSchedule.test.js b/server/test/eventSchedule.test.js
new file mode 100644
index 0000000..0b8652e
--- /dev/null
+++ b/server/test/eventSchedule.test.js
@@ -0,0 +1,391 @@
+// ── Expansion and the calendar (EVENTS_PLAN.md Phase 4) ────────────────────
+//
+// The phase's shipped claim: **a published definition with a recurrence produces
+// occurrences on its own, and the calendar shows the ones that exist beside the
+// ones that will.** The arithmetic underneath is proved separately in
+// `eventRecurrence.test.js`; this file is about the two decisions the org lead
+// took on 2026-09-02 and the properties they imply:
+//
+// • occurrences become REAL ROWS inside a fourteen-day horizon, and beyond it
+// the calendar projects rather than materialising
+// • a projection is never emitted for an instant a run already occupies — so
+// the fortnight inside the horizon is not drawn twice, and a CANCELLED
+// occurrence does not come back as a forecast
+// • expansion looks forward from `now - grace` only, so an occurrence nobody
+// could ever have seen is not invented retroactively
+// • only `ready` definitions expand: publishing IS the schedule switch (§E)
+// and archiving is how an operator turns one off
+// • expansion is idempotent, because it runs every fifteen seconds for ever
+//
+// Stubbed at the `.db` layer, the shape `eventRunner.test.js` uses.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const runner = require('../src/utils/eventRunner')
+const calendarModel = require('../src/model/events/eventCalendar.model')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
+const runsDb = require('../src/model/events/eventRuns.db')
+const stepsDb = require('../src/model/events/eventRunSteps.db')
+const logDb = require('../src/model/events/eventRunLog.db')
+const versionsDb = require('../src/model/events/eventVersions.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+// A Tuesday. Chosen so a "friday" schedule has its first occurrence three days
+// out — inside the horizon, but not today, which is what keeps "materialised"
+// and "due" from being confusable in these fixtures.
+const NOW = new Date('2026-09-01T12:00:00Z')
+
+const SPEC = {
+ schedule: { kind: 'weekly', days: ['friday'], time: '20:00' },
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+}
+
+let store
+const originals = {}
+for (const [name, mod] of [
+ ['definitionsDb', definitionsDb],
+ ['runsDb', runsDb],
+ ['stepsDb', stepsDb],
+ ['logDb', logDb],
+ ['versionsDb', versionsDb],
+]) {
+ originals[name] = { mod, fns: { ...mod } }
+}
+
+const restoreOriginals = () => {
+ for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
+}
+
+const clone = (o) => JSON.parse(JSON.stringify(o))
+
+/** One `ready` definition with a published version carrying `spec`. */
+function addDefinition(id, overrides = {}) {
+ const definition = {
+ id,
+ title: `Event ${id}`,
+ slug: `event-${id}`,
+ state: 'ready',
+ timezone: 'UTC',
+ grace_seconds: 900,
+ concurrency_key: null,
+ current_version_id: id * 100,
+ series_id: null,
+ series_name: null,
+ series_slug: null,
+ spec: clone(SPEC),
+ ...overrides,
+ }
+ store.definitions.set(id, definition)
+ store.versions.set(definition.current_version_id, {
+ id: definition.current_version_id,
+ definition_id: id,
+ version: 1,
+ spec: definition.spec,
+ })
+ return definition
+}
+
+function installStubs() {
+ store = { definitions: new Map(), versions: new Map(), runs: [], steps: [], log: [], nextRunId: 1 }
+
+ Object.assign(definitionsDb, {
+ findSchedulable: async () =>
+ [...store.definitions.values()]
+ .filter((d) => d.state === 'ready' && d.current_version_id)
+ .map((d) => ({ ...d, version_spec: store.versions.get(d.current_version_id)?.spec || null })),
+ getById: async (id) => store.definitions.get(id) || null,
+ list: async () => [...store.definitions.values()],
+ })
+
+ Object.assign(versionsDb, { getById: async (id) => store.versions.get(id) || null })
+
+ Object.assign(runsDb, {
+ materialise: async (run) => {
+ const at = new Date(run.scheduled_for).getTime()
+ // The unique index, in memory: one row per (definition, scope, instant).
+ const clash = store.runs.find(
+ (r) => r.definition_id === run.definition_id && r.scope === (run.scope || '') && new Date(r.scheduled_for).getTime() === at,
+ )
+ if (clash) return null
+ const id = store.nextRunId++
+ const definition = store.definitions.get(run.definition_id)
+ store.runs.push({
+ ...run,
+ id,
+ scope: run.scope || '',
+ status: 'scheduled',
+ health: 'ok',
+ waiting_steps: 0,
+ definition_title: definition?.title,
+ definition_slug: definition?.slug,
+ series_id: definition?.series_id ?? null,
+ series_name: definition?.series_name ?? null,
+ series_slug: definition?.series_slug ?? null,
+ version_number: 1,
+ })
+ return id
+ },
+ getById: async (id) => store.runs.find((r) => r.id === id) || null,
+ findOccurrence: async (definitionId, scope, at) =>
+ store.runs.find(
+ (r) => r.definition_id === definitionId && r.scope === (scope || '') && new Date(r.scheduled_for).getTime() === new Date(at).getTime(),
+ ) || null,
+ listInWindow: async ({ from, to, status = null, scope = null, seriesId = null }) =>
+ store.runs
+ .filter((r) => {
+ const at = new Date(r.scheduled_for).getTime()
+ if (at < new Date(from).getTime() || at >= new Date(to).getTime()) return false
+ if (status && r.status !== status) return false
+ if (scope !== null && scope !== undefined && r.scope !== scope) return false
+ if (seriesId && Number(r.series_id) !== Number(seriesId)) return false
+ return true
+ })
+ .sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for)),
+ })
+
+ Object.assign(stepsDb, { materialisePhase: async () => [] })
+ Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } })
+}
+
+beforeEach(() => {
+ registries._reset()
+ registries.registerCore()
+ installStubs()
+})
+
+afterEach(restoreOriginals)
+
+const instants = () => store.runs.map((r) => new Date(r.scheduled_for).toISOString()).sort()
+
+// ── Expansion ──────────────────────────────────────────────────────────────
+
+test('a weekly definition materialises exactly the occurrences inside the horizon', async () => {
+ addDefinition(1)
+ const created = await runner.expandSchedules(NOW)
+
+ // 1 September 2026 is a Tuesday. Fridays inside 14 days: the 4th and the 11th.
+ assert.equal(created, 2)
+ assert.deepEqual(instants(), ['2026-09-04T20:00:00.000Z', '2026-09-11T20:00:00.000Z'])
+})
+
+test('expansion is idempotent — running it again creates nothing', async () => {
+ // The property the whole design leans on: this runs every fifteen seconds for
+ // ever. `INSERT IGNORE` against the occurrence key is what makes that free,
+ // and a second call that created rows would be a duplicate event, not a
+ // duplicate row.
+ addDefinition(1)
+ assert.equal(await runner.expandSchedules(NOW), 2)
+ assert.equal(await runner.expandSchedules(NOW), 0)
+ assert.equal(await runner.expandSchedules(new Date(NOW.getTime() + 60_000)), 0)
+ assert.equal(store.runs.length, 2)
+})
+
+test('only `ready` definitions expand — publishing is the switch, archiving turns it off', async () => {
+ addDefinition(1, { state: 'draft' })
+ addDefinition(2, { state: 'archived' })
+ addDefinition(3, { state: 'ready' })
+ await runner.expandSchedules(NOW)
+ assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [3])
+})
+
+test('a draft edit cannot materialise anything — the VERSION spec is what expands', async () => {
+ // The definition's working copy says daily; the published version says weekly.
+ // A half-typed recurrence an author is midway through must never produce a run.
+ const definition = addDefinition(1)
+ definition.spec = {
+ schedule: { kind: 'weekly', days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], time: '20:00' },
+ phases: SPEC.phases,
+ }
+ await runner.expandSchedules(NOW)
+ assert.equal(store.runs.length, 2)
+})
+
+test('a manual definition expands to nothing at all', async () => {
+ addDefinition(1, { spec: { schedule: { kind: 'manual' }, phases: SPEC.phases } })
+ store.versions.get(100).spec = store.definitions.get(1).spec
+ assert.equal(await runner.expandSchedules(NOW), 0)
+ assert.equal(store.runs.length, 0)
+})
+
+test('an occurrence older than the grace window is never materialised at all', async () => {
+ // Not materialised-then-swept. A row nobody could ever have seen or cancelled
+ // is not history, and writing one would put a `missed` event on the calendar
+ // for a date on which this deployment had no such event. The horizon is what
+ // makes the missed sweep meaningful instead: a real outage finds rows already
+ // there, because they were written a fortnight early.
+ addDefinition(1, { grace_seconds: 900 })
+ // A Monday, three days after the Friday occurrence — far outside the grace.
+ await runner.expandSchedules(new Date('2026-09-07T12:00:00Z'))
+ assert.ok(!instants().includes('2026-09-04T20:00:00.000Z'))
+})
+
+test('an occurrence still inside the grace window IS materialised', async () => {
+ // The case this rule exists for: a definition published four minutes before
+ // its own first occurrence. `now - grace` is the window start, so the
+ // occurrence that has only just passed is still created and still startable.
+ addDefinition(1, { grace_seconds: 3600 })
+ await runner.expandSchedules(new Date('2026-09-04T20:10:00Z'))
+ assert.ok(instants().includes('2026-09-04T20:00:00.000Z'))
+})
+
+test('a DST-adjusted occurrence records WHY its clock reads oddly', async () => {
+ // Discovering daylight saving at 3am on the last Sunday in October is the
+ // failure this line exists to prevent.
+ addDefinition(1, {
+ timezone: 'Europe/Berlin',
+ spec: { schedule: { kind: 'weekly', days: ['sunday'], time: '02:30' }, phases: SPEC.phases },
+ })
+ store.versions.get(100).spec = store.definitions.get(1).spec
+
+ await runner.expandSchedules(new Date('2026-03-22T12:00:00Z'))
+ const adjusted = store.log.find((l) => l.detail?.dstAdjusted)
+ assert.equal(adjusted.detail.dstAdjusted, 'gap')
+ assert.equal(adjusted.detail.timezone, 'Europe/Berlin')
+ assert.ok(instants().includes('2026-03-29T01:00:00.000Z'))
+})
+
+test('a definition whose spec is nonsense is skipped, and the sweep carries on', async () => {
+ // A spec written straight into the database with a shape the validator would
+ // have refused is a bad row, not a bad tick.
+ addDefinition(1, { spec: { schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, phases: SPEC.phases } })
+ store.versions.get(100).spec = store.definitions.get(1).spec
+ addDefinition(2)
+
+ const created = await runner.expandSchedules(NOW)
+ assert.equal(created, 2)
+ assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [2])
+})
+
+test('every materialised occurrence is marked as coming from the schedule', async () => {
+ // `started_by` is NULL for a scheduled occurrence and for one an admin started
+ // whose account has since gone, so the log is the only place the two are told
+ // apart.
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ const created = store.log.filter((l) => l.kind === 'run.created' && l.detail?.source)
+ assert.equal(created.length, 2)
+ for (const line of created) {
+ assert.equal(line.detail.source, 'schedule')
+ assert.equal(line.detail.by, null)
+ }
+})
+
+// ── The calendar ───────────────────────────────────────────────────────────
+
+test('inside the horizon the calendar shows runs; beyond it, projections', async () => {
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-10-01T00:00:00Z'),
+ now: NOW,
+ })
+
+ const kinds = result.entries.map((e) => `${e.kind} ${new Date(e.scheduledFor).toISOString().slice(0, 10)}`)
+ assert.deepEqual(kinds, [
+ 'run 2026-09-04',
+ 'run 2026-09-11',
+ 'projected 2026-09-18',
+ 'projected 2026-09-25',
+ ])
+ // The forecast is arithmetic and says so: no row, nothing to open.
+ for (const entry of result.entries.filter((e) => e.kind === 'projected')) {
+ assert.equal(entry.runId, null)
+ assert.equal(entry.status, null)
+ }
+})
+
+test('a projection is never drawn over an instant a run already occupies', async () => {
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-09-15T00:00:00Z'),
+ now: NOW,
+ })
+ assert.equal(result.entries.length, 2)
+ assert.ok(result.entries.every((e) => e.kind === 'run'))
+})
+
+test('a CANCELLED occurrence does not come back as a forecast', async () => {
+ // The same rule, and the case it earns its keep on. An operator who called an
+ // event off must not find it on the calendar again ten seconds later looking
+ // like it is still coming.
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ store.runs[0].status = 'cancelled'
+
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-09-15T00:00:00Z'),
+ now: NOW,
+ })
+ const onTheDay = result.entries.filter((e) => new Date(e.scheduledFor).toISOString().startsWith('2026-09-04'))
+ assert.equal(onTheDay.length, 1)
+ assert.equal(onTheDay[0].kind, 'run')
+ assert.equal(onTheDay[0].status, 'cancelled')
+})
+
+test('a status filter suppresses projections, because a forecast has no status', async () => {
+ addDefinition(1)
+ await runner.expandSchedules(NOW)
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-10-01T00:00:00Z'),
+ status: 'scheduled',
+ now: NOW,
+ })
+ assert.ok(result.entries.every((e) => e.kind === 'run'))
+ assert.equal(result.entries.length, 2)
+})
+
+test('a series filter narrows runs and projections alike', async () => {
+ addDefinition(1, { series_id: 7, series_name: 'Royal Spy Mission' })
+ addDefinition(2, { series_id: 9, series_name: 'Something Else' })
+ await runner.expandSchedules(NOW)
+
+ const result = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-10-01T00:00:00Z'),
+ seriesId: 7,
+ now: NOW,
+ })
+ assert.ok(result.entries.length > 2)
+ assert.ok(result.entries.every((e) => e.seriesName === 'Royal Spy Mission'))
+ assert.ok(result.entries.some((e) => e.kind === 'projected'))
+})
+
+test('the window is bounded, inverted windows are refused, and the horizon is reported', async () => {
+ const wide = await calendarModel.calendar({
+ from: new Date('2026-01-01T00:00:00Z'),
+ to: new Date('2027-01-01T00:00:00Z'),
+ now: NOW,
+ })
+ assert.equal(wide.ok, false)
+ assert.equal(wide.status, 400)
+ assert.match(wide.errors.join(' '), /at most 92 days/)
+
+ const inverted = await calendarModel.calendar({
+ from: new Date('2026-09-10T00:00:00Z'),
+ to: new Date('2026-09-01T00:00:00Z'),
+ now: NOW,
+ })
+ assert.equal(inverted.ok, false)
+
+ const fine = await calendarModel.calendar({
+ from: new Date('2026-09-01T00:00:00Z'),
+ to: new Date('2026-09-15T00:00:00Z'),
+ horizonDays: 14,
+ now: NOW,
+ })
+ assert.equal(fine.ok, true)
+ assert.equal(fine.horizon.toISOString(), '2026-09-15T12:00:00.000Z')
+})
diff --git a/server/test/eventSeries.test.js b/server/test/eventSeries.test.js
new file mode 100644
index 0000000..b733639
--- /dev/null
+++ b/server/test/eventSeries.test.js
@@ -0,0 +1,113 @@
+// ── Event series, the arc (EVENTS.md §D/§I, Phase 4) ───────────────────────
+//
+// One small table, and the reason it is worth testing at all is the two rules
+// that are not obvious from its four columns:
+//
+// • the slug is derived once and FROZEN. The public arc page lives at it, so
+// a rename that moved it would break every link — including the ones inside
+// the Discord posts this feature will eventually write.
+// • the delete is a real delete, and it is the only one in this feature. A
+// definition is archived instead, because a run pins its version and history
+// that cannot be explained defeats the audit. A series pins nothing: it is a
+// label, `series_id` is ON DELETE SET NULL, and the count of what it detached
+// is what an operator needs to be told.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const seriesDb = require('../src/model/events/eventSeries.db')
+const series = require('../src/model/events/eventSeries.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+let store
+const original = { ...seriesDb }
+
+beforeEach(() => {
+ store = { rows: new Map(), nextId: 1 }
+ Object.assign(seriesDb, {
+ list: async () => [...store.rows.values()].sort((a, b) => a.ordering - b.ordering || a.id - b.id),
+ getById: async (id) => store.rows.get(id) || null,
+ exists: async (id) => store.rows.has(id),
+ insert: async (s) => {
+ const id = store.nextId++
+ store.rows.set(id, { ...s, id, definition_count: 0 })
+ return id
+ },
+ update: async (id, s) => {
+ const existing = store.rows.get(id)
+ store.rows.set(id, { ...existing, ...s })
+ return 1
+ },
+ remove: async (id) => {
+ store.rows.delete(id)
+ return 1
+ },
+ })
+})
+
+afterEach(() => Object.assign(seriesDb, original))
+
+test('a series is created with a slug derived from its name', async () => {
+ const result = await series.create({ name: 'Royal Spy Mission', ordering: 2 }, 7)
+ assert.equal(result.ok, true)
+ assert.equal(result.status, 201)
+ assert.equal(result.series.slug, 'royal-spy-mission')
+ assert.equal(result.series.ordering, 2)
+ assert.equal(result.series.created_by, 7)
+})
+
+test('two series with the same name get distinct slugs', async () => {
+ // `slug` is UNIQUE in the schema, so without this the second create is a 1452
+ // reaching a controller as a 500.
+ const first = await series.create({ name: 'Winter Arc' })
+ const second = await series.create({ name: 'Winter Arc' })
+ assert.equal(first.series.slug, 'winter-arc')
+ assert.equal(second.series.slug, 'winter-arc-2')
+})
+
+test('renaming a series does NOT move its slug', async () => {
+ // The rule with teeth. The arc page lives at the slug, and a rename is the
+ // ordinary act of an editor tidying up wording months later.
+ const created = await series.create({ name: 'Royal Spy Mission' })
+ const updated = await series.update(created.series.id, { name: 'The Royal Spy Missions' })
+ assert.equal(updated.ok, true)
+ assert.equal(updated.series.name, 'The Royal Spy Missions')
+ assert.equal(updated.series.slug, 'royal-spy-mission')
+})
+
+test('a nameless series is refused, and so is a nonsense ordering', async () => {
+ assert.deepEqual((await series.create({ name: ' ' })).errors, ['name is required'])
+ const bad = await series.create({ name: 'Fine', ordering: -3 })
+ assert.equal(bad.ok, false)
+ assert.match(bad.errors.join(' '), /ordering must be an integer/)
+})
+
+test('deleting a series answers with how many definitions it detached', async () => {
+ // The whole consequence of this delete is about the rows it does NOT delete,
+ // so the count is the answer rather than a detail.
+ const created = await series.create({ name: 'Winter Arc' })
+ store.rows.get(created.series.id).definition_count = 3
+
+ const removed = await series.remove(created.series.id)
+ assert.equal(removed.ok, true)
+ assert.equal(removed.detached, 3)
+ assert.equal(await seriesDb.getById(created.series.id), null)
+})
+
+test('acting on a series that is not there is a 404, never a 500', async () => {
+ assert.equal((await series.update(999, { name: 'x' })).status, 404)
+ assert.equal((await series.remove(999)).status, 404)
+})
+
+test('ordering places a series among the others, and defaults to zero', async () => {
+ await series.create({ name: 'Third', ordering: 30 })
+ await series.create({ name: 'First', ordering: 10 })
+ await series.create({ name: 'Unordered' })
+ const listed = await series.list()
+ assert.deepEqual(listed.map((s) => s.name), ['Unordered', 'First', 'Third'])
+})
diff --git a/server/test/eventSpec.test.js b/server/test/eventSpec.test.js
index 307cdad..5d995f6 100644
--- a/server/test/eventSpec.test.js
+++ b/server/test/eventSpec.test.js
@@ -192,13 +192,104 @@ test('a key a later phase owns is refused, not silently preserved', () => {
assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
})
-test('only the manual schedule exists in this phase', () => {
- const weekly = spec.validate({
- schedule: { kind: 'weekly', days: ['fri'], time: '20:00' },
- phases: [{ key: 'main', label: 'Main', steps: [] }],
+// ── The schedule shapes (Phase 4) ────────────────────────────────────
+//
+// Every check here is on SHAPE. What the shapes MEAN — the zone arithmetic, the
+// DST rules — is `eventRecurrence.test.js`. The split is deliberate: this file
+// answers "may this be saved", that one answers "when does it happen", and the
+// second question is only worth asking of something that passed the first.
+
+const withSchedule = (schedule) =>
+ spec.validate({ schedule, phases: [{ key: 'main', label: 'Main', steps: [] }] })
+
+test('the four closed shapes are accepted and normalised', () => {
+ assert.deepEqual(withSchedule({ kind: 'manual' }).spec.schedule, { kind: 'manual' })
+ assert.deepEqual(withSchedule({ kind: 'once', at: '2026-10-31T20:00' }).spec.schedule, {
+ kind: 'once',
+ at: '2026-10-31T20:00',
})
- assert.equal(weekly.ok, false)
- assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/)
+ assert.deepEqual(withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00' }).spec.schedule, {
+ kind: 'weekly',
+ days: ['friday'],
+ time: '20:00',
+ })
+ assert.deepEqual(
+ withSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }).spec.schedule,
+ { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
+ )
+})
+
+test('the validator accepts its own output for every shape', () => {
+ // Phase 1's rule, and it is a rule about the SECOND save of any definition
+ // rather than about a round trip for its own sake: `validate` normalises, and
+ // publish re-validates what a save wrote. A normaliser that refuses what it
+ // emits makes a published definition uneditable.
+ for (const schedule of [
+ { kind: 'manual' },
+ { kind: 'once', at: '2026-10-31T20:00' },
+ { kind: 'weekly', days: ['friday', 'monday'], time: '20:00' },
+ { kind: 'monthly', nth: 4, weekday: 'friday', time: '19:30' },
+ ]) {
+ const first = withSchedule(schedule)
+ assert.equal(first.ok, true, JSON.stringify(schedule))
+ const second = withSchedule(first.spec.schedule)
+ assert.equal(second.ok, true, JSON.stringify(first.spec.schedule))
+ assert.deepEqual(second.spec.schedule, first.spec.schedule)
+ }
+})
+
+test('weekly days are normalised into week order and deduped', () => {
+ // Not tidiness. The spec is snapshotted into a version and diffed, so two
+ // orderings of the same schedule would show as an edit nobody made.
+ const result = withSchedule({ kind: 'weekly', days: ['Friday', 'monday', 'FRIDAY'], time: '20:00' })
+ assert.deepEqual(result.spec.schedule.days, ['monday', 'friday'])
+})
+
+test('a shape may not carry another shape keys', () => {
+ const result = withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00', at: '2026-01-01T00:00' })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /unknown key\(s\) at for kind "weekly"/)
+})
+
+test('an unknown kind is refused, and the message names the four', () => {
+ const result = withSchedule({ kind: 'daily', time: '20:00' })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /manual, once, weekly, monthly/)
+})
+
+test('a date that is not a real day is refused', () => {
+ // The regex admits 2026-02-30 quite happily. A schedule that parses and then
+ // resolves to some other day is worse than one that is refused.
+ const result = withSchedule({ kind: 'once', at: '2026-02-30T20:00' })
+ assert.equal(result.ok, false)
+ assert.match(result.errors.join('\n'), /is not a real date/)
+})
+
+test('every malformed schedule field is named, not merely rejected', () => {
+ assert.match(withSchedule({ kind: 'once', at: 'soon' }).errors.join('\n'), /YYYY-MM-DDTHH:MM/)
+ assert.match(withSchedule({ kind: 'weekly', days: [], time: '20:00' }).errors.join('\n'), /non-empty array/)
+ assert.match(withSchedule({ kind: 'weekly', days: ['froday'], time: '20:00' }).errors.join('\n'), /unknown weekday/)
+ assert.match(withSchedule({ kind: 'weekly', days: ['friday'], time: '25:00' }).errors.join('\n'), /24-hour time/)
+ assert.match(
+ withSchedule({ kind: 'monthly', nth: 5, weekday: 'friday', time: '19:30' }).errors.join('\n'),
+ /1, 2, 3, 4 or -1/,
+ )
+ assert.match(
+ withSchedule({ kind: 'monthly', nth: 1, weekday: 'froday', time: '19:30' }).errors.join('\n'),
+ /expected one of sunday/,
+ )
+})
+
+test('a refused schedule leaves a manual one behind rather than half a recurrence', () => {
+ // `validate` collects every error and carries on, so the spec object exists
+ // even when the answer is no. A caller reading `schedule.days` of it must not
+ // find a partially built weekly.
+ const result = spec.validate({
+ schedule: { kind: 'weekly', days: ['froday'], time: '20:00' },
+ phases: [{ key: 'BAD KEY', label: '', steps: [] }],
+ })
+ assert.equal(result.ok, false)
+ assert.ok(result.errors.length > 1)
})
test('every problem is reported, not just the first', () => {
diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js
index 3f589a9..62ccb82 100644
--- a/server/test/eventsAdmin.test.js
+++ b/server/test/eventsAdmin.test.js
@@ -160,6 +160,21 @@ function installStubs() {
.filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status))
.map(shapeRun)
runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined)
+ runsDb.listScheduledFor = async (definitionId) =>
+ [...store.runs.values()]
+ .filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at)
+ .map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for }))
+ runsDb.repinScheduled = async (definitionId, versionId) => {
+ let moved = 0
+ for (const run of store.runs.values()) {
+ if (run.definition_id !== definitionId) continue
+ if (run.status !== 'scheduled' || run.started_at) continue
+ if (run.version_id === versionId) continue
+ run.version_id = versionId
+ moved += 1
+ }
+ return moved
+ }
runsDb.materialise = async (run) => {
const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for)
if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job
@@ -595,3 +610,56 @@ test('the list filters by state, and an unknown id is 404 rather than 500', asyn
const bad = await call(ctrl.get, { params: { id: 'not-a-number' } })
assert.equal(bad.statusCode, 400)
})
+
+
+test('publishing re-pins the occurrences that have not started, and says how many', async () => {
+ // The case an operator meets on their SECOND edit of any recurring event: a
+ // fortnight of occurrences is already on the calendar, each carrying the spec
+ // as it was. Left alone, an edit reaches none of them and the only recourse --
+ // cancelling each -- makes the occurrence vanish rather than come back, because
+ // a cancelled row still holds its slot in `uq_evrun_occurrence`.
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+
+ const ahead = await call(ctrl.startRun, {
+ params: { id: String(id) },
+ body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' },
+ })
+ assert.equal(ahead.statusCode, 201)
+ const aheadId = ahead.body.run.id
+ const v1 = store.runs.get(aheadId).version_id
+
+ // A second occurrence, this one already under way. Its pin is what makes it
+ // explicable afterwards, so it must not move.
+ const inFlight = await call(ctrl.startRun, {
+ params: { id: String(id) },
+ body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' },
+ })
+ const inFlightId = inFlight.body.run.id
+ store.runs.get(inFlightId).status = 'running'
+ store.runs.get(inFlightId).started_at = new Date()
+
+ const republished = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(republished.statusCode, 200)
+ assert.equal(republished.body.version, 2)
+ assert.equal(republished.body.repinned, 1)
+
+ assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId)
+ assert.notEqual(store.runs.get(aheadId).version_id, v1)
+ assert.equal(store.runs.get(inFlightId).version_id, v1)
+
+ // The move is on the run's own log, because "which version did this actually
+ // use" is the first question an audit asks.
+ const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned)
+ assert.equal(line.detail.fromVersionId, v1)
+ assert.equal(line.detail.toVersionId, republished.body.versionId)
+})
+
+test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
+ const created = await createDraft()
+ const id = created.body.event.id
+ await call(ctrl.publish, { params: { id: String(id) } })
+ const again = await call(ctrl.publish, { params: { id: String(id) } })
+ assert.equal(again.body.repinned, 0)
+})
--
2.49.1
From 9bc0bf5a3d1e2d6d5e3bd85c62a4ab9296e531f2 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 2 Sep 2026 22:11:20 -0500
Subject: [PATCH 05/18] feat(events): conditions, phase advancement and the
diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '', where:
, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.
`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.
One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.
A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
---
client/src/api/client.js | 2 +
client/src/lib/eventAuthoring.js | 109 ++++++-
client/src/routes/admin/views/EventEditor.jsx | 79 ++++-
client/src/routes/admin/views/EventRun.jsx | 125 +++++++-
client/test/eventAuthoring.test.js | 141 +++++++++
server/db/schema.sql | 61 ++++
server/routes.guards.json | 9 +
server/routes.manifest.json | 4 +
server/src/events/gates.js | 244 +++++++++++++++
server/src/events/spec.js | 193 +++++++++++-
server/src/model/events/eventPhaseGates.db.js | 187 +++++++++++
.../model/events/eventRunControls.model.js | 86 ++++-
server/src/model/events/eventRunLog.db.js | 7 +
server/src/model/events/eventRuns.db.js | 29 +-
server/src/model/events/eventRuns.model.js | 26 +-
.../src/router/v1/admin/events.controller.js | 48 +++
server/src/router/v1/admin/events.router.js | 30 +-
server/src/utils/engagementEmit.js | 15 +
server/src/utils/eventRunner.js | 130 +++++++-
server/swagger/swagger-output.json | 106 ++++++-
server/test/eventGates.test.js | 267 ++++++++++++++++
server/test/eventRunControls.test.js | 123 +++++++-
server/test/eventRunner.test.js | 293 +++++++++++++++++-
server/test/eventRunnerSql.test.js | 206 ++++++++++++
server/test/eventSpec.test.js | 165 +++++++++-
server/test/eventsAdmin.test.js | 12 +
26 files changed, 2646 insertions(+), 51 deletions(-)
create mode 100644 server/src/events/gates.js
create mode 100644 server/src/model/events/eventPhaseGates.db.js
create mode 100644 server/test/eventGates.test.js
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 8f979af..d847b85 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -522,6 +522,8 @@ export const api = {
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
cancelEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason } }),
+ advanceEventRun: (runId, reason) =>
+ req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
confirmEventStep: (runId, stepId, note) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
skipEventStep: (runId, stepId, reason) =>
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 77b8073..ee437c1 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -47,14 +47,27 @@ export function lastStartedSeqOf(steps, phase) {
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
* is not happening" is a decision made before a run starts as often as during
* one.
+ *
+ * **`advance` is offered only when the phase is genuinely waiting on its gate**,
+ * which is the same test the server makes and is stated here in the same words
+ * on purpose: this decides what is *offered*, the server decides what is
+ * *allowed*, and a button that is present and always refused is the "control
+ * that answers 409 and does nothing" this feature has refused twice. The gate
+ * must be open-and-unsatisfied AND no step of the phase may still be pending or
+ * running — a phase held by a step is held by the step, and skip is its control.
*/
-export function runControlsFor(run) {
- if (!run) return { pause: false, resume: false, cancel: false }
+export function runControlsFor(run, gates = [], steps = []) {
+ if (!run) return { pause: false, resume: false, cancel: false, advance: false }
const terminal = isTerminalRun(run.status)
+ const gate = (gates || []).find((g) => g.phase === run.currentPhase)
+ const stepOpen = (steps || []).some(
+ (s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
+ )
return {
pause: ['starting', 'running'].includes(run.status),
resume: run.status === 'paused',
cancel: !terminal,
+ advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
}
}
@@ -125,7 +138,41 @@ export function blankStep(action) {
}
export function blankPhase(phases) {
- return { key: nextPhaseKey(phases), label: 'New phase', steps: [] }
+ return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() }
+}
+
+/**
+ * The advance gate as the FORM holds it (Phase 5) — three fields that are
+ * always present and mostly empty, rather than a discriminated union the form
+ * has to rebuild every time the dropdown moves.
+ *
+ * `kind: ''` is "no condition", which is what nearly every phase is and what
+ * every phase was before this. The form keeps a half-typed `on` gate's trigger
+ * while the author looks at `after`, because a dropdown that discards what was
+ * typed under the other option is one an operator learns to be afraid of.
+ */
+export function blankAdvance() {
+ return { kind: '', after: '30m', on: '', count: 1, whereText: '' }
+}
+
+export const ADVANCE_KINDS = [
+ { value: '', label: 'When its steps are done' },
+ { value: 'after', label: 'After a fixed delay' },
+ { value: 'on', label: 'When something happens in the game' },
+]
+
+/** The stored gate, as the form's three fields. */
+export function advanceFormFrom(advance) {
+ const blank = blankAdvance()
+ if (!advance) return blank
+ if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
+ return {
+ ...blank,
+ kind: 'on',
+ on: advance.on || '',
+ count: advance.count ?? 1,
+ whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '',
+ }
}
/** The editor's working state, from what `GET /admin/events/:id` returned. */
@@ -145,6 +192,7 @@ export function formFromDefinition(event) {
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
+ advance: advanceFormFrom(p.advance),
steps: (p.steps || []).map((s) => ({
actionId: s.actionId || '',
label: s.label || '',
@@ -157,6 +205,31 @@ export function formFromDefinition(event) {
}
}
+/**
+ * One phase's advance gate, as the spec shape — or null when it has none.
+ *
+ * Only the `where` JSON is checked, and only because text that is not JSON
+ * cannot be put in a request at all. **Whether the predicate is VALID is the
+ * server's answer**, and the whole trap of Phase 5 is that it is answered at
+ * save with the offending variable named — re-deciding it here would be a second
+ * validator drifting from the one that matters, exactly as with a step's params.
+ */
+export function advancePayload(advance, where, errors) {
+ if (!advance || !advance.kind) return null
+ if (advance.kind === 'after') return { after: advance.after }
+
+ const out = { on: advance.on, count: Number(advance.count) || 1 }
+ const text = String(advance.whereText || '').trim()
+ if (text) {
+ try {
+ out.where = JSON.parse(text)
+ } catch (err) {
+ errors.push(`${where}, advance condition: ${err.message}`)
+ }
+ }
+ return out
+}
+
/**
* The form, as a request body — or the list of everything wrong with it.
*
@@ -173,9 +246,16 @@ export function formFromDefinition(event) {
*/
export function payloadFromForm(form) {
const errors = []
- const phases = (form.phases || []).map((phase, pi) => ({
+ const phases = (form.phases || []).map((phase, pi) => {
+ const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors)
+ return {
key: phase.key,
label: phase.label,
+ // Omitted rather than sent as null when there is no gate, which is what
+ // `events/spec.js` stores for the same reason: a spec full of
+ // `"advance": null` makes the first phase to gain one look like an edit to
+ // every phase in the version diff.
+ ...(where ? { advance: where } : {}),
steps: (phase.steps || []).map((step, si) => {
const out = { actionId: step.actionId }
if (step.label) out.label = step.label
@@ -188,7 +268,8 @@ export function payloadFromForm(form) {
}
return out
}),
- }))
+ }
+ })
if (errors.length) return { ok: false, errors }
@@ -376,6 +457,9 @@ const KIND_WORDS = {
'step.status': 'Step',
'step.retry': 'Step retried',
'step.parked': 'Waiting on a human',
+ 'phase.gate': 'Advance condition set',
+ 'condition.evaluated': 'Condition evaluated',
+ 'phase.advanced': 'Phase advanced',
note: 'Note',
}
@@ -415,6 +499,21 @@ export function describeLogLine(line) {
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
+ case 'phase.gate':
+ return d.kind === 'after'
+ ? `${line.phase} advances ${d.after} after it started`
+ : `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
+ // Both outcomes are logged, and the near miss is the useful one: it is the
+ // difference between "the boss did spawn, in the wrong region" and "no boss
+ // has spawned", which look identical on every other line of this log.
+ case 'condition.evaluated':
+ return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${
+ d.satisfied ? ', condition met' : ''
+ }`
+ case 'phase.advanced':
+ return d.because === 'forced'
+ ? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
+ : `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
default:
return logKindWord(line?.kind)
}
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index 2e37ff6..3ad300e 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -7,6 +7,8 @@ import {
formFromDefinition,
payloadFromForm,
blankPhase,
+ blankAdvance,
+ ADVANCE_KINDS,
blankStep,
describeSchedule,
scheduleFromForm,
@@ -105,6 +107,12 @@ export default function EventEditor() {
const actions = useMemo(() => catalog?.actions || [], [catalog])
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
+ // Phase 5. Served with the actions on the same route, so an EDITOR sees the
+ // same catalog an admin does — `/admin/engagement/triggers` is admin-only, and
+ // an editor writing a trigger id from memory into a field the save path then
+ // refuses is the failure this avoids.
+ const triggers = useMemo(() => catalog?.triggers || [], [catalog])
+ const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
@@ -467,10 +475,77 @@ export default function EventEditor() {
The key is what the run console groups by and what “phase 3 has not started” names, so it
- cannot change once runs exist. A phase advances when every one of its steps is finished;
- advancing on a condition instead is a later phase.
+ cannot change once runs exist.
+ {/* ── The advance condition (Phase 5) ──
+ A gate is an ADDITIONAL condition and never a replacement, which is
+ what the caption has to say: a phase whose steps are still running
+ is not advanced by a boss that spawned early. */}
+
+ {phase.advance?.kind
+ ? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
+ : 'The phase advances the moment every one of its steps is finished.'}
+
+
+
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)
diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx
index c82b020..d1b00a8 100644
--- a/client/src/routes/admin/views/EventRun.jsx
+++ b/client/src/routes/admin/views/EventRun.jsx
@@ -29,6 +29,15 @@ import {
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
// stay that way for ever unless somebody presses confirm. It is called out above
// the step list rather than being one row in it.
+//
+// **Phase 5 gave it a second one of those, and the panel is this phase's real
+// deliverable** (§ Observability): a phase whose steps have all finished and
+// whose advance condition has not been met is also `running` and also
+// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
+// steps, in the condition builder's own words — and the sentence is the
+// SERVER'S. `gates[].where` arrives already rendered, because those labels are
+// defined in the condition grammar and a second renderer in the browser would
+// be a second opinion about what `gte` reads as.
const POLL_MS = 5000
@@ -52,11 +61,89 @@ const STEP_COLOR = {
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
+/**
+ * Seconds as an operator reads them — the same vocabulary the spec authors a
+ * gate in, so "28 min" on this screen and `after: '30m'` in the editor are
+ * obviously the same kind of thing.
+ */
+function elapsed(seconds) {
+ const s = Math.max(0, Number(seconds) || 0)
+ if (s < 60) return `${s} sec`
+ if (s < 3600) return `${Math.floor(s / 60)} min`
+ const h = Math.floor(s / 3600)
+ const m = Math.floor((s % 3600) / 60)
+ return m ? `${h} hr ${m} min` : `${h} hr`
+}
+
+/**
+ * One phase gate, as the panel draws it.
+ *
+ * The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
+ * was released by the third boss" is the same question as the live one, asked
+ * after the fact, and it is the one an operator asks the morning after.
+ */
+function GateRow({ gate, current }) {
+ const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
+ return (
+
+ {gate.lastEvent.trigger} at {clock(gate.lastEventAt)}
+ {' — '}
+ {/* The near miss is the valuable half: "the boss did spawn, in
+ Britain" and "no boss has spawned" are different answers and
+ look identical without this line. */}
+ {gate.lastEvent.matched ? 'counted' : 'did not count'}
+ {Object.keys(gate.lastEvent.variables || {}).length > 0 && (
+
+ {' ('}
+ {Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
+ {')'}
+
+ )}
+
)}
+ {/* ── What this run changed in the world (Phase 8) ──
+ The WHOLE ledger, reverted rows included: "how much did last night's
+ invasion actually spawn, and did all of it come back" is one question
+ with two halves, and a list of only the failures answers neither.
+ Shown on finished runs for the same reason the caps meter is. */}
+ {(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
+
0 ? '#d9c184' : 'var(--rule)'}`,
+ }}
+ >
+
+
+ What this run changed
+
+ {/* The manual retry. Offered only on a terminal run, because a run
+ still in flight has a ledger that is still growing and reverting a
+ resource the next step is about to use would be undoing an event
+ while it is happening. */}
+ {isTerminalRun(run.status) && unresolved > 0 && (
+ act(() => api.admin.cleanupEventRun(run.id))}>
+ Try cleanup again
+
+ )}
+
+
+ {unresolved > 0 ? (
+ <>
+ {unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner
+ gives them back on its own and stops asking after a few tries;{' '}
+ Try cleanup again clears that count and asks once more.
+ >
+ ) : (
+ 'Everything this run created or borrowed has been given back.'
+ )}
+
+ {resources.length === 0 ? (
+
+ Nothing named — a step changed the world and its answer never arrived, so core kept the
+ record it wrote beforehand and will ask the module to undo it by key.
+
+ )}
+
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
diff --git a/server/db/schema.sql b/server/db/schema.sql
index fd86d56..76744bb 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -2090,7 +2090,7 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
-- module contract. The rest arrive with the phases that give them a writer
-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5,
-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in
--- P8 and `event_run_participants` in P10.
+-- P8 (below) and `event_run_participants` in P10.
--
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
-- action id, a scope, a resource kind and a budget dimension are all opaque
@@ -2492,6 +2492,101 @@ CREATE TABLE IF NOT EXISTS event_run_budget (
UNIQUE KEY uq_evbud_dim (run_id, dimension)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- The cleanup ledger: everything one run created or leased, and what became of
+-- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8).
+--
+-- **It holds both kinds of thing an event owns.** An OBJECT it created is
+-- `kind: 'creature'` with `ref` a serial, reverted by its own action's
+-- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and
+-- `payload` carrying the baseline and what was applied, restored by the lease's
+-- own `restore()`. One table, because cleanup asks both the same question --
+-- what is still out there, and did putting it back work.
+--
+-- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial
+-- does not exist until the module answers, so what is written before the dispatch
+-- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved
+-- '@step', `ref` = that key). On the answer the reported resources are inserted
+-- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the
+-- placeholder survives, and cleanup calls `revert()` with the idempotency key and
+-- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })`
+-- takes the key at all. Recording afterwards instead would make every object
+-- whose ack was lost invisible to cleanup for ever.
+--
+-- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row
+-- that never reverts stays visible -- the run reaches `completed` with
+-- `cleanup_status = 'incomplete'` rather than being held `running`, because a
+-- tidy `completed` over a shard full of orphaned monsters is the failure that
+-- would end this feature's credibility on its first bad night.
+--
+-- **The unique key is what stops two events leasing one target**, and it must
+-- hold among LIVE rows only: last week's finished event must not keep this
+-- week's from leasing the same rate. MariaDB has no partial index, so the
+-- encoding is a STORED generated column that is NULL once the row is no longer
+-- ours -- and multiple NULLs do not collide in a unique index. It is derived from
+-- `status` ALONE and the opaque columns stay in the KEY, which is the shape
+-- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a
+-- foreign key whose column is a base column of a stored generated column
+-- (error 1901), so `step_id` must not appear in the expression.
+--
+-- **The key is held by the three statuses that mean "core still believes this is
+-- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that
+-- mean it is not. §D says "among non-reverted rows", which was written before the
+-- six statuses had their meanings; taken literally it makes `drifted` and
+-- `orphaned` hold a target for ever, so one bad night would disable a lease
+-- permanently with no control able to clear it. `drifted` means somebody else has
+-- hold of the value and this run has deliberately let go of it; `orphaned` means
+-- it vanished. Neither is a claim on the target, and both stay LOUD by another
+-- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console --
+-- which is what §L's rule 2 actually asks for. Amended 2026-09-03.
+CREATE TABLE IF NOT EXISTS event_run_resources (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ run_id BIGINT NOT NULL,
+ -- Which step made it. It is how cleanup finds the ACTION to call `revert()` on:
+ -- the row records the module and the opaque names, and the step records the
+ -- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record
+ -- of what was changed in the world must outlive the row that scheduled it.
+ step_id BIGINT NULL,
+ -- The registering module, copied at record time rather than derived from the
+ -- action id, so an uninstalled module still names itself on the console.
+ owner_module VARCHAR(64) NOT NULL,
+ -- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s
+ -- treatment. '@step' is the one reserved `kind` and core owns it.
+ kind VARCHAR(64) NOT NULL,
+ ref VARCHAR(190) NOT NULL,
+ payload JSON NULL,
+ -- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as
+ -- well: the game side restores baseline when it passes, without being asked
+ -- again, which is the fail-safe that makes an unattended world change
+ -- defensible. This column is core's copy of that promise, for the console and
+ -- for the boot-time check.
+ lease_until DATETIME NULL,
+ status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
+ NOT NULL DEFAULT 'pending',
+ -- Bounded like a step's `attempts`, and for the same reason: a revert that can
+ -- never succeed must become visible rather than cycling for ever. Engagement
+ -- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what
+ -- makes an unbounded counter a row nothing can ever sweep.
+ revert_attempts INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ -- Optional, and module-opaque like the rest: who received it, for a granted
+ -- reward that results should be able to name. `event_run_participants` joins on
+ -- the same key in Phase 10.
+ member_key VARCHAR(190) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ -- 1 while core still believes this resource is this run's, NULL once it is not.
+ -- See the unique key below; derived from `status` alone, deliberately.
+ live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
+ CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
+ CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
+ -- "Two events cannot hold a lease on one target", among non-reverted rows.
+ UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
+ -- The run console, and the cleanup sweep's read: one run's ledger in order.
+ INDEX idx_evres_run (run_id, status),
+ -- The cleanup leg's scan across runs, and the boot-time lease self-check.
+ INDEX idx_evres_live (status, lease_until)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- §K's last bound: "a scheduled definition that has never been verified is the
-- case worth refusing to start". A version is immutable, so a dry run that passed
-- against it stays true — which is what makes the pass a property of the VERSION
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 63d1265..b2c5dd5 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -581,6 +581,15 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/cleanup",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 5a85a9e..31caf15 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -257,6 +257,10 @@
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cancel"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/runs/:runId/cleanup"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log"
diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js
index 1d171f0..e124ac7 100644
--- a/server/src/config/coreEventActions.js
+++ b/server/src/config/coreEventActions.js
@@ -13,6 +13,15 @@
// a human to go and do something. A deployment with no game module installed has
// a working event system made of exactly these.
//
+// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
+// names no game noun either — it borrows a value some module declared — but
+// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
+// and therefore default-off, admin-only and cap-checked like any module verb.
+// It is CORE's rather than each module's because §F puts the duration bound and
+// the two-events-one-target conflict check on core's side of the seam: a lease
+// verb per module would be that bound re-implemented once per module, advisory
+// everywhere, and wrong in the first one that forgot it.
+//
// **Phase 2 gave all three real bodies**, and between them they exercise every
// shape §F's envelope can take: `core.announce` does work and finishes,
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
@@ -24,10 +33,42 @@
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
// looked up inside `perform()`, per call, which is also what makes a leg
-// registered by a module that booted later reachable at all.
+// registered by a module that booted later reachable at all. `core.lease` is the
+// one action here that reaches a table, and it requires the model INSIDE
+// `perform()` for the same reason — a top-level require would make this file
+// build a pool during route-manifest generation.
const registries = require('../modules/registries')
+
+/**
+ * Turn the `value` param's text into whatever the named lease says it holds.
+ *
+ * The range check is here too, and it is REQUIRED on the numeric types for the
+ * reason §F gives: unlike a cap, a bad lease value is in force the moment it is
+ * applied, so "0.5 to 5" is not advice.
+ */
+function coerceLeaseValue(lease, raw) {
+ const text = String(raw === undefined || raw === null ? '' : raw).trim()
+ if (lease.type === 'string') return { ok: true, value: text }
+ if (lease.type === 'bool') {
+ if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
+ if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
+ return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
+ }
+ const n = Number(text)
+ if (text === '' || !Number.isFinite(n)) {
+ return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
+ }
+ if (lease.type === 'int' && !Number.isInteger(n)) {
+ return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
+ }
+ if (n < lease.min || n > lease.max) {
+ return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
+ }
+ return { ok: true, value: n }
+}
+
const ACTIONS = [
{
id: 'core.announce',
@@ -214,6 +255,164 @@ const ACTIONS = [
return { ok: true, await: 'human' }
},
},
+
+ {
+ id: 'core.lease',
+ label: 'Borrow a value',
+ description:
+ 'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
+
+ // The world changes and it changes back, so `change` rather than
+ // `irreversible` — and `change`'s default `on_failure` is `pause`, which is
+ // the right stop for a run that failed halfway through altering the world.
+ risk: 'change',
+ // The one action core ships in this class. `override` is what tells the
+ // cleanup sweep to restore through the LEASE registry rather than through an
+ // action's `revert()`, which is why this action needs no `revert()` of its own
+ // and why the registry refuses one on it.
+ reversible: 'override',
+ version: 1,
+
+ params: [
+ {
+ name: 'lease',
+ type: 'string',
+ required: true,
+ example: 'uo.rate.skillgain',
+ source: 'core.options.leases',
+ description: 'Which declared value to borrow.',
+ },
+ {
+ // **A string, and the coercion is here rather than in the type system.**
+ // A param declares ONE type; a lease declares its own, and they are four
+ // different ones. Typing this `float` would make a boolean lease
+ // unauthorable and a string lease nonsense, so the field takes text and
+ // this action turns it into whatever the named lease said it holds — the
+ // one place that knows both halves.
+ name: 'value',
+ type: 'string',
+ required: true,
+ example: '3.0',
+ description: 'What to hold it at, in whatever type the lease declares.',
+ },
+ {
+ name: 'minutes',
+ type: 'int',
+ required: true,
+ example: 120,
+ description: 'How long to hold it. Core refuses more than the lease allows.',
+ },
+ ],
+
+ // What a lease costs is the LEASE's business to bound, not a budget's:
+ // `maxDurationMs` and the numeric range are declared beside the callables and
+ // enforced below. A cap dimension here would be core inventing an accounting
+ // unit for something a module already bounds — and `registerEventBudgets`
+ // refuses a dimension nobody declared, which is exactly the rule that would
+ // then bite core's own action.
+
+ /**
+ * Read the baseline, reserve the target, apply the value.
+ *
+ * **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
+ * is knowable before the dispatch — it is the lease id the step names — so
+ * the ledger row is written with its real `kind` and `ref` BEFORE anything
+ * touches the world, and the two-events-one-target refusal comes from the
+ * unique index at that moment rather than from a check that read and then
+ * wrote. A second run asking for a lease another run holds comes back
+ * `refused`, in the same words a cap breach uses and for the same reason:
+ * nothing is broken, the deployment already has that value spoken for.
+ *
+ * The order is read then reserve then apply, and a failure at each stage
+ * undoes the one before it: a reservation whose `apply` refuses is released
+ * here rather than left for the sweep, because there is nothing out there to
+ * give back and a shard that is merely down must not lock a lease out for the
+ * length of a retry cycle.
+ */
+ async perform({ runId, stepId, params, verify }) {
+ // eslint-disable-next-line global-require
+ const resourcesDb = require('../model/events/eventRunResources.db')
+ const lease = registries.eventLease(params.lease)
+ if (!lease) {
+ return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
+ }
+
+ const coerced = coerceLeaseValue(lease, params.value)
+ if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
+
+ const minutes = Number(params.minutes)
+ if (!Number.isFinite(minutes) || minutes <= 0) {
+ return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
+ }
+ const ms = Math.round(minutes * 60_000)
+ if (ms > lease.maxDurationMs) {
+ return {
+ ok: false,
+ retry: false,
+ error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
+ }
+ }
+
+ // **The dry run stops here, and it has still checked everything worth
+ // checking**: the lease exists, the value is in range and the duration is
+ // allowed. What it deliberately does not do is reserve the target — a
+ // verify that took a lease would be a dry run that changed something, and
+ // it would then refuse the real run that followed it.
+ if (verify) return { ok: true }
+
+ const baseline = await lease.read()
+ if (!baseline || baseline.ok !== true) {
+ return { ok: false, error: `could not read the current value of ${lease.label}` }
+ }
+
+ const until = new Date(Date.now() + ms)
+ const reserved = await resourcesDb.reserve({
+ runId,
+ stepId,
+ owner: lease.owner || 'core',
+ kind: 'override',
+ ref: lease.id,
+ payload: { target: lease.id, baseline: baseline.value, applied: coerced.value, until: until.toISOString() },
+ leaseUntil: until,
+ })
+ if (!reserved.ok) {
+ const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
+ return {
+ ok: false,
+ retry: false,
+ error: `${lease.label} is already leased by another run${heldBy}`,
+ }
+ }
+
+ // **`until` goes down the wire** (§F). The module passes it to its sidecar
+ // and the game side restores baseline when it passes, without being asked
+ // again — the fail-safe that makes an unattended, scheduled world change
+ // defensible, because the worst case is a world back at baseline early
+ // rather than one stuck changed indefinitely.
+ let applied
+ try {
+ applied = await lease.apply(coerced.value, until)
+ } catch (err) {
+ applied = { ok: false, error: err.message }
+ }
+ if (!applied || applied.ok !== true) {
+ await resourcesDb.markReverted(reserved.id)
+ return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
+ }
+
+ await resourcesDb.confirm(reserved.id)
+ // **The run now owes the world something, and something has to say so.**
+ // The generic path marks a run dirty when it records a module's reported
+ // resources; this action reserves its own row and never goes through it, so
+ // a run whose only resource was a lease would have kept `cleanup_status =
+ // 'not_required'` and never been swept. Found by the live walk, and the
+ // cleanup leg's own scan was widened to make the class impossible rather
+ // than only this instance.
+ // eslint-disable-next-line global-require
+ await require('../events/ledger').markRunDirty(runId)
+ return { ok: true }
+ },
+ },
]
// ── Core's own param option sources (§F, Phase 7) ──────────────────
@@ -237,6 +436,16 @@ const OPTION_SOURCES = [
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
},
},
+ {
+ id: 'core.options.leases',
+ label: 'Borrowable values',
+ description: 'Every value a module has declared this deployment may lease.',
+ async resolve() {
+ return registries
+ .allEventLeases()
+ .map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
+ },
+ },
]
module.exports = { ACTIONS, OPTION_SOURCES }
diff --git a/server/src/events/cleanup.js b/server/src/events/cleanup.js
new file mode 100644
index 0000000..4fe2af5
--- /dev/null
+++ b/server/src/events/cleanup.js
@@ -0,0 +1,427 @@
+// ── Giving back what a run took ────────────────────────────────────────────
+//
+// EVENTS.md §C ("Cleanup is generated, never authored"), §L and its two ledger
+// rules, and Phase 8 of EVENTS_PLAN.md. `events/ledger.js` is the write half;
+// this is the undo half, plus the reconcile that answers "is any of it still
+// there?" after something outside core restarted.
+//
+// **Cleanup is derived from the ledger, never authored.** An operator cannot be
+// relied on to write the undo, and an aborted run never reaches the phase they
+// wrote it in — so there is no cleanup phase in a spec and no `on_teardown` on an
+// action. There is one function, it reads rows, and it runs on EVERY terminal
+// path: completion, cancellation and abort alike.
+//
+// **It is not built out of `event_run_steps` rows** (org lead, 2026-09-03). The
+// plan's phrase is "cleanup steps are generated from the ledger", and the
+// tempting reading is a synthetic phase of real step rows so the console's
+// per-step retry comes free. It is the wrong shape here for one concrete reason:
+// `event_run_resources` already carries `revert_attempts` and `last_error`, so
+// synthetic steps would put a second retry counter beside the first and the two
+// would disagree the first time a step reverted three of its four resources.
+// The manual retry the API surface promises is a route over the ledger —
+// `POST /admin/events/runs/:runId/cleanup` — rather than a step control.
+//
+// **Where it runs from.** One place: the runner's cleanup leg, which finds
+// terminal runs that still owe the world something and works their rows. Hooking
+// each terminal path instead would be four call sites, three of which are inside
+// a request, and none of which would survive the process dying mid-cleanup. The
+// leg is ordered AFTER advance in the tick, so a run that completes in one tick
+// is cleaned in the same one.
+//
+// **The scan's WHERE clause cost two live-walk findings, in opposite
+// directions.** A run whose only resource was a LEASE never went through
+// `ledger.markRunDirty` — `core.lease` reserves its own row — so its
+// `cleanup_status` stayed `not_required` and the lease was never given back at
+// all. And a run whose first sweep failed was moved to `incomplete` by that very
+// sweep, so it was never picked up again: `MAX_REVERT_ATTEMPTS` meant ONE attempt
+// rather than three. The first is why `not_required` is in the scan; the second
+// is why `incomplete` is written HERE only once nothing retryable is left.
+//
+// **Rule 2 is what the bounds are for.** A revert that never succeeds must stay
+// visible rather than cycle: `MAX_REVERT_ATTEMPTS` stops the automatic retry, the
+// run reaches `completed` with `cleanup_status = 'incomplete'`, and the rows stay
+// on the console with their last error. Only a human's cleanup clears the
+// counter — Engagement Phase 14's rule, whose defect was a sweep that reset every
+// stale row and made the ceiling unreachable for ever.
+
+const resourcesDb = require('../model/events/eventRunResources.db')
+const runsDb = require('../model/events/eventRuns.db')
+const logDb = require('../model/events/eventRunLog.db')
+const stepsDb = require('../model/events/eventRunSteps.db')
+const registries = require('../modules/registries')
+const { withDeadline } = require('./dispatch')
+const log = require('../utils/logger')('events')
+
+// How many times the automatic sweep will ask before leaving a resource for a
+// human. Three, like a step's, and for the same reason: a fourth attempt against
+// a shard that has answered the same way three times is not new information.
+const MAX_REVERT_ATTEMPTS = Number(process.env.EVENT_REVERT_MAX_ATTEMPTS) || 3
+
+// The bound on one revert call, when the action that made the resource is gone
+// and there is no `budgetMs` to read. A restore is a round trip like any other.
+const DEFAULT_REVERT_BUDGET_MS = 10_000
+
+// How many runs one cleanup leg looks at, and how many resource groups it works
+// per run. Bounds rather than targets, exactly like `RUN_BATCH`: the tick runs
+// again, and an unbounded teardown is how one run's bad night stalls every other.
+const CLEANUP_RUN_BATCH = Number(process.env.EVENT_CLEANUP_RUN_BATCH) || 10
+const CLEANUP_GROUPS_PER_RUN = Number(process.env.EVENT_CLEANUP_GROUPS_PER_RUN) || 25
+
+/**
+ * Classify one revert answer, with `dispatch.classify`'s posture: no shape a
+ * failure can take may read as success.
+ *
+ * The extra value here is `drifted`. It is NOT an error — the module did exactly
+ * what it was asked and found somebody else's value in place — so it is a third
+ * outcome rather than a failure with a flag, and the row it produces is the one
+ * §L wants surfaced beside the unreverted ones.
+ */
+function classifyRevert(raw, what) {
+ if (raw && raw.__timedOut) return { outcome: 'retry', error: raw.error }
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
+ return { outcome: 'retry', error: `${what} answered with no envelope` }
+ }
+ if (raw.ok === true) {
+ // §L, and the Rust wipe: "gone, and that is fine" is a successful revert. A
+ // module never has to distinguish "I deleted it" from "it was not there".
+ return { outcome: 'done', failed: Array.isArray(raw.failed) ? raw.failed.map(String) : [] }
+ }
+ if (raw.drifted === true) {
+ return {
+ outcome: 'drifted',
+ error: `the value is now ${JSON.stringify(raw.current)} rather than what this run applied, so it was left alone`,
+ }
+ }
+ return {
+ outcome: raw.retry === false ? 'terminal' : 'retry',
+ error: raw.error ? String(raw.error) : `${what} refused`,
+ }
+}
+
+/** Call a lease's `restore`, under a deadline, never throwing. */
+async function restoreLease(row) {
+ const lease = registries.eventLease(row.ref)
+ if (!lease) {
+ // The module that owned it is uninstalled or failed to boot. Not a failure to
+ // retry away — nothing will change until an operator reinstalls it — and not
+ // an orphan either, because core has no idea whether the value is still
+ // applied. It stays unresolved with the reason on it, which is exactly what
+ // `cleanup_status = 'incomplete'` is for.
+ return { outcome: 'terminal', error: `no module registers the lease "${row.ref}"` }
+ }
+ const payload = row.payload || {}
+ let raw
+ try {
+ raw = await withDeadline(
+ () => lease.restore(payload.baseline, { expected: payload.applied, runId: row.run_id }),
+ DEFAULT_REVERT_BUDGET_MS,
+ row.ref,
+ )
+ } catch (err) {
+ return { outcome: 'retry', error: err.message }
+ }
+ return classifyRevert(raw, row.ref)
+}
+
+/** Call an action's `revert` over a group of its rows, under a deadline, never throwing. */
+async function revertGroup(actionId, rows, idempotencyKey) {
+ const action = registries.eventAction(actionId)
+ if (!action || typeof action.revert !== 'function') {
+ return {
+ outcome: 'terminal',
+ error: action
+ ? `${actionId} declares no revert()`
+ : `no module registers "${actionId}", so its resources cannot be given back`,
+ }
+ }
+ const payload = rows
+ .filter((r) => r.kind !== resourcesDb.STEP_KIND)
+ .map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null, memberKey: r.member_key || null }))
+
+ let raw
+ try {
+ raw = await withDeadline(
+ () => action.revert({ runId: rows[0].run_id, resources: payload, idempotencyKey }),
+ action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
+ actionId,
+ )
+ } catch (err) {
+ // A module should not throw from `revert` any more than from `perform`, and
+ // one that does has produced a transient failure rather than a crashed sweep.
+ log.warn('event revert threw', { action: actionId, message: err.message })
+ return { outcome: 'retry', error: err.message }
+ }
+ return classifyRevert(raw, actionId)
+}
+
+/**
+ * Work one run's ledger once.
+ *
+ * Answers what it found and what it managed, and sets `cleanup_status` from the
+ * rows that are left rather than from what it did — the two differ whenever
+ * another writer touched the run, and the rows are the truth.
+ *
+ * `resetAttempts` is the human's flag. It is never set by the automatic leg.
+ */
+async function cleanupRun(run, { resetAttempts = false, actor = null } = {}) {
+ const summary = { attempted: 0, reverted: 0, drifted: 0, failed: 0, remaining: 0 }
+
+ if (resetAttempts) {
+ const cleared = await resourcesDb.resetAttempts(run.id)
+ await runsDb.setCleanupStatus(run.id, 'pending')
+ if (cleared) {
+ await logDb.write({
+ runId: run.id,
+ kind: 'cleanup.retry',
+ detail: { resources: cleared, by: actor },
+ })
+ }
+ }
+
+ const rows = await resourcesDb.unresolvedForRun(run.id, {
+ maxAttempts: resetAttempts ? null : MAX_REVERT_ATTEMPTS,
+ })
+
+ // **Grouped by the step that made them**, because that is what names the verb:
+ // the resource row records the module and the opaque names, the step records
+ // the action, and `revert()` takes a LIST so one round trip can give back
+ // twelve creatures. A lease is its own group of one — core restores it through
+ // the lease registry rather than through any action, which is the split §F
+ // draws and the reason `core.lease` needs no `revert()` of its own.
+ const leases = rows.filter((r) => r.kind === 'override')
+ const byStep = new Map()
+ for (const row of rows) {
+ if (row.kind === 'override') continue
+ const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
+ if (!byStep.has(key)) byStep.set(key, [])
+ byStep.get(key).push(row)
+ }
+
+ const groups = [...leases.map((r) => ({ lease: r })), ...[...byStep.values()].map((rs) => ({ rows: rs }))]
+
+ for (const group of groups.slice(0, CLEANUP_GROUPS_PER_RUN)) {
+ if (group.lease) {
+ const row = group.lease
+ if (!(await resourcesDb.claimRevert(row.id))) continue
+ summary.attempted += 1
+ const verdict = await restoreLease(row)
+ await applyVerdict(run, [row], verdict, summary, row.ref)
+ continue
+ }
+
+ const rs = group.rows
+ // The step is what names the action and carries the idempotency key. A row
+ // whose step was deleted keeps the action id in its own payload, which is why
+ // the placeholder writes one.
+ const step = rs[0].step_id === null ? null : await stepsDb.getById(rs[0].step_id)
+ const actionId = step?.action_id || rs[0].payload?.action || null
+ if (!actionId) {
+ await noteUnrevertable(run, rs, 'nothing records which action created this', summary)
+ continue
+ }
+ const claimed = []
+ for (const row of rs) if (await resourcesDb.claimRevert(row.id)) claimed.push(row)
+ if (!claimed.length) continue
+ summary.attempted += claimed.length
+ const verdict = await revertGroup(actionId, claimed, step?.idempotency_key || rs[0].ref)
+ await applyVerdict(run, claimed, verdict, summary, actionId)
+ }
+
+ summary.remaining = await resourcesDb.unresolvedCount(run.id)
+ // **`incomplete` means "finished with, and not finished"**, so it is written
+ // only once there is nothing left this sweep will try. Writing it after the
+ // FIRST failure — which is what the first draft did — took the run straight out
+ // of the leg's own scan, and `MAX_REVERT_ATTEMPTS` quietly meant one attempt
+ // rather than three. Found by the live walk, watching `revert_attempts` sit at
+ // 1 through half a minute of ticks.
+ const retryable = await resourcesDb.unresolvedForRun(run.id, { maxAttempts: MAX_REVERT_ATTEMPTS })
+ const status = summary.remaining === 0 ? 'complete' : retryable.length ? 'pending' : 'incomplete'
+ await runsDb.setCleanupStatus(run.id, status)
+
+ if (summary.attempted > 0) {
+ await logDb.write({
+ runId: run.id,
+ kind: 'cleanup.swept',
+ detail: { ...summary, by: actor },
+ })
+ }
+ return summary
+}
+
+/** Write one verdict across the rows it covers, and count it. */
+async function applyVerdict(run, rows, verdict, summary, what) {
+ for (const row of rows) {
+ if (verdict.outcome === 'done' && !(verdict.failed || []).includes(row.ref)) {
+ await resourcesDb.markReverted(row.id)
+ summary.reverted += 1
+ continue
+ }
+ if (verdict.outcome === 'drifted') {
+ await resourcesDb.failRevert(row.id, verdict.error, 'drifted')
+ summary.drifted += 1
+ continue
+ }
+ const error =
+ verdict.outcome === 'done'
+ ? `${what} could not give "${row.ref}" back`
+ : verdict.error
+ await resourcesDb.failRevert(row.id, error, 'confirmed')
+ summary.failed += 1
+ }
+ await logDb.write({
+ runId: run.id,
+ kind: verdict.outcome === 'done' ? 'cleanup.reverted' : 'cleanup.failed',
+ detail: {
+ what,
+ outcome: verdict.outcome,
+ resources: rows.map((r) => `${r.kind}:${r.ref}`),
+ ...(verdict.error ? { error: verdict.error } : {}),
+ },
+ })
+}
+
+/** A group core cannot even name a verb for. Counted as failed, and said once. */
+async function noteUnrevertable(run, rows, reason, summary) {
+ for (const row of rows) {
+ if (!(await resourcesDb.claimRevert(row.id))) continue
+ await resourcesDb.failRevert(row.id, reason, 'confirmed')
+ summary.attempted += 1
+ summary.failed += 1
+ }
+ await logDb.write({
+ runId: run.id,
+ kind: 'cleanup.failed',
+ detail: { what: null, outcome: 'terminal', resources: rows.map((r) => `${r.kind}:${r.ref}`), error: reason },
+ })
+}
+
+/**
+ * The cleanup leg of the tick: every TERMINAL run with something left to give
+ * back.
+ *
+ * Terminal only. A run still in flight has a ledger that is still growing, and
+ * reverting a resource the next step is about to use would be core undoing an
+ * event while it is happening.
+ */
+async function sweep() {
+ // The ceiling goes INTO the query, so a run whose rows are all spent is not
+ // selected, worked over and found to have nothing to do on every tick for the
+ // rest of its life. It is also what excludes a run an admin cancelled without
+ // cleanup, whose counters were spent deliberately.
+ const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
+ let swept = 0
+ for (const candidate of candidates) {
+ if (!runsDb.TERMINAL.includes(candidate.status)) continue
+ try {
+ await cleanupRun(candidate)
+ swept += 1
+ } catch (err) {
+ log.error('event cleanup failed', { run: candidate.id, message: err.message })
+ }
+ }
+ return swept
+}
+
+/**
+ * Ask one module which of its ledgered resources the game still has (§L, and
+ * §N7's "the shard stays stateless about events").
+ *
+ * **Core cannot know when to ask**, and that is not an omission: §F says core has
+ * no concept of the game being up, because a module with six sidecars cannot
+ * answer that question in the singular. So the module triggers this, through
+ * `ctx.events.reconcile()`, when it sees its own reconnect — module-uo already
+ * watches `bootId` for exactly that. Core also asks once at boot, for its own
+ * restart.
+ *
+ * **A resource the module no longer has becomes `orphaned`, never `reverted`.**
+ * Reverting it would be core recording that it put something back when what
+ * actually happened is that the thing vanished while nobody was looking, and the
+ * two are different sentences to the operator reading the console afterwards.
+ *
+ * A module with no `reconcile()` on the action is not broken: core keeps
+ * believing its own ledger, which is precisely the behaviour before this phase.
+ */
+async function reconcileModule(owner) {
+ const rows = await resourcesDb.liveForModule(owner)
+ const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
+ if (!rows.length) return summary
+
+ const byStep = new Map()
+ for (const row of rows) {
+ if (row.kind === resourcesDb.STEP_KIND) continue // nothing to ask about yet
+ const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
+ if (!byStep.has(key)) byStep.set(key, [])
+ byStep.get(key).push(row)
+ }
+
+ for (const group of byStep.values()) {
+ const step = group[0].step_id === null ? null : await stepsDb.getById(group[0].step_id)
+ const actionId = step?.action_id || group[0].payload?.action || null
+ const action = actionId ? registries.eventAction(actionId) : null
+ if (!action || typeof action.reconcile !== 'function') {
+ summary.unanswered += group.length
+ continue
+ }
+ summary.asked += group.length
+ let raw
+ try {
+ raw = await withDeadline(
+ () =>
+ action.reconcile({
+ runId: group[0].run_id,
+ resources: group.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null })),
+ }),
+ action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
+ actionId,
+ )
+ } catch (err) {
+ log.warn('event reconcile threw', { action: actionId, message: err.message })
+ raw = null
+ }
+ // Same posture as everywhere else: nothing that is not an explicit answer
+ // counts as one. A module that could not answer leaves the ledger alone,
+ // because "I do not know" must never be read as "it is gone".
+ if (!raw || raw.__timedOut || raw.ok !== true || !Array.isArray(raw.inForce)) {
+ summary.unanswered += group.length
+ continue
+ }
+ const held = new Set(raw.inForce.map(String))
+ for (const row of group) {
+ if (held.has(row.ref)) {
+ summary.inForce += 1
+ continue
+ }
+ await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
+ summary.orphaned += 1
+ await logDb.write({
+ runId: row.run_id,
+ kind: 'resource.orphaned',
+ detail: { module: owner, resource: `${row.kind}:${row.ref}`, action: actionId },
+ })
+ }
+ }
+ return summary
+}
+
+/** Ask every module that owns a live row. Core's own boot-time sweep. */
+async function reconcileAll() {
+ const owners = await resourcesDb.modulesWithLiveRows()
+ const out = {}
+ for (const owner of owners) {
+ try {
+ out[owner] = await reconcileModule(owner)
+ } catch (err) {
+ log.error('event reconcile failed', { module: owner, message: err.message })
+ }
+ }
+ return out
+}
+
+module.exports = {
+ MAX_REVERT_ATTEMPTS,
+ classifyRevert,
+ cleanupRun,
+ sweep,
+ reconcileModule,
+ reconcileAll,
+}
diff --git a/server/src/events/ledger.js b/server/src/events/ledger.js
new file mode 100644
index 0000000..7638619
--- /dev/null
+++ b/server/src/events/ledger.js
@@ -0,0 +1,221 @@
+// ── Recording what a run changed in the world ──────────────────────────────
+//
+// EVENTS.md §D and §L, and Phase 8 of EVENTS_PLAN.md. The write half of the
+// resource ledger; `events/cleanup.js` is the read-and-undo half.
+//
+// **Rule 1 is the whole reason this file is not two lines inside `drainStep`.**
+// A resource is recorded BEFORE it is confirmed. The obstacle is that a spawn's
+// serial does not exist until the module answers, so there is nothing to write a
+// row about yet — which is why what goes in before the dispatch is a PLACEHOLDER
+// keyed by the step's idempotency key rather than by the object:
+//
+// pre-dispatch INSERT pending { kind: '@step', ref: }
+// answer INSERT confirmed { kind: 'creature', ref: '0x40001234' } × n
+// resolve the placeholder
+// ack lost the placeholder is still `pending`
+// cleanup revert({ idempotencyKey, resources: [] })
+//
+// That last line is why §F's `revert({ runId, resources, idempotencyKey })` takes
+// the key at all. A module that half-ran and never answered is reachable by its
+// key and by nothing else, and Phase 11's plugin-side key ledger is what makes
+// answering it exact. Until then the contract is still honest, because §L
+// requires reverting something that does not exist to be a SUCCESS.
+//
+// **Recording is idempotent, and the database is what makes it so.** A retry
+// re-dispatches the same idempotency key, and a module that answers with the same
+// resources twice must not produce two rows. `uq_evres_target` refuses the second
+// insert, and this file reads that refusal as "already recorded" rather than as an
+// error — the same posture `materialisePhase`'s INSERT IGNORE takes.
+//
+// **A lease does not use the placeholder.** Its target is knowable before the
+// dispatch — it is the lease id the step names — so `core.lease` reserves the
+// real row first, which is both a stronger form of rule 1 and the only place the
+// two-events-one-target refusal can happen before the world has been written to.
+
+const resourcesDb = require('../model/events/eventRunResources.db')
+const runsDb = require('../model/events/eventRuns.db')
+const registries = require('../modules/registries')
+const log = require('../utils/logger')('events')
+
+// Which reversible classes get a pre-dispatch placeholder. `none` is gone once
+// done and `self` undoes itself, so neither has anything core could come back
+// for; `override` reserves its own target instead (see the header). That leaves
+// `ledger` — the class that declares `revert()`, which is exactly the class whose
+// refs core cannot know until the module speaks.
+const PLACEHOLDER_CLASSES = ['ledger']
+
+// A resource `kind` may be anything a module likes except core's own reserved
+// one. Bounded to the column, and refused rather than truncated: a truncated ref
+// is a cleanup call naming the wrong object.
+const MAX_KIND = 64
+const MAX_REF = 190
+const MAX_MEMBER_KEY = 190
+
+/** Does this action produce anything core will have to come back for? */
+const ledgers = (action) => action && (action.reversible === 'ledger' || action.reversible === 'override')
+
+/**
+ * Turn one entry of a module's `resources` array into a row, or say why not.
+ *
+ * Every failure here is the module's mistake rather than the world's, so none of
+ * them is a retry: a badly shaped resource will be just as badly shaped on the
+ * second attempt. They are logged and dropped, and the step still counts as done
+ * — because it IS done; something happened in the world, and refusing to record
+ * it would be the one outcome worse than recording it imperfectly.
+ */
+function normalise(entry, actionId) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ return { ok: false, reason: `${actionId} reported a resource that is not an object` }
+ }
+ const kind = String(entry.kind || '')
+ const ref = String(entry.ref === undefined || entry.ref === null ? '' : entry.ref)
+ if (!kind || kind.length > MAX_KIND) {
+ return { ok: false, reason: `${actionId} reported a resource with a bad kind "${entry.kind}"` }
+ }
+ if (kind === resourcesDb.STEP_KIND) {
+ // Core's own. A module that could write one would be a module that could make
+ // its own step's placeholder look resolved.
+ return { ok: false, reason: `${actionId} reported a resource of the reserved kind "${kind}"` }
+ }
+ if (!ref || ref.length > MAX_REF) {
+ return { ok: false, reason: `${actionId} reported a resource with a bad ref "${entry.ref}"` }
+ }
+ const memberKey = entry.memberKey === undefined || entry.memberKey === null ? null : String(entry.memberKey)
+ if (memberKey !== null && memberKey.length > MAX_MEMBER_KEY) {
+ return { ok: false, reason: `${actionId} reported a resource with an over-long memberKey` }
+ }
+
+ let leaseUntil = null
+ if (entry.until !== undefined && entry.until !== null) {
+ const at = new Date(entry.until)
+ if (Number.isNaN(at.getTime())) {
+ return { ok: false, reason: `${actionId} reported a resource with a bad until "${entry.until}"` }
+ }
+ leaseUntil = at
+ }
+
+ // A borrowed value must name a lease core knows how to give back. Core restores
+ // an `override` through the lease registry — that is the split §F draws — so a
+ // ref naming nothing registered is a resource core would be recording with no
+ // way to undo it, which is the promise rule 2 exists to stop core making.
+ if (kind === 'override' && !registries.eventLease(ref)) {
+ return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
+ }
+
+ return {
+ ok: true,
+ row: {
+ kind,
+ ref,
+ payload: entry.payload === undefined ? null : entry.payload,
+ leaseUntil,
+ memberKey,
+ },
+ }
+}
+
+/**
+ * Write the pre-dispatch placeholder for a step that is about to change the
+ * world. Answers the row id, or null when this action ledgers nothing.
+ *
+ * **A duplicate is not a failure.** A retry re-uses its step's idempotency key, so
+ * the second attempt's placeholder collides with the first's — and finding it
+ * already there is the correct answer, not an error. The existing row is reused.
+ */
+async function reserveStep(run, step, action) {
+ if (!ledgers(action) || !PLACEHOLDER_CLASSES.includes(action.reversible)) return null
+
+ const owner = action.owner || 'core'
+ const reserved = await resourcesDb.reserve({
+ runId: run.id,
+ stepId: step.id,
+ owner,
+ kind: resourcesDb.STEP_KIND,
+ ref: step.idempotency_key,
+ payload: { action: action.id, phase: step.phase, seq: step.seq },
+ })
+ if (reserved.ok) {
+ await markRunDirty(run.id)
+ return reserved.id
+ }
+ // The only way a '@step' row collides is with this step's own earlier attempt,
+ // because an idempotency key is minted once per step and never varies by
+ // attempt (§E). Reuse it.
+ const existing = await resourcesDb.findByTarget(owner, resourcesDb.STEP_KIND, step.idempotency_key)
+ return existing ? existing.id : null
+}
+
+/**
+ * Record what a module said it made, and close out the placeholder.
+ *
+ * Answers `{ recorded, rejected }` — how many rows went in, and the reasons any
+ * entry was dropped. Never throws: a step that changed the world has changed it,
+ * and a ledger that threw would turn a bookkeeping problem into a failed step and
+ * then into a retry of a world write that already happened.
+ */
+async function recordAnswer({ run, step, action, placeholderId, resources }) {
+ const out = { recorded: 0, rejected: [] }
+ if (!ledgers(action)) return out
+
+ const owner = action.owner || 'core'
+ const list = Array.isArray(resources) ? resources : []
+
+ for (const entry of list) {
+ const parsed = normalise(entry, action.id)
+ if (!parsed.ok) {
+ out.rejected.push(parsed.reason)
+ log.warn('event resource rejected', { run: run.id, step: step.id, reason: parsed.reason })
+ continue
+ }
+ try {
+ const reserved = await resourcesDb.reserve({
+ runId: run.id,
+ stepId: step.id,
+ owner,
+ kind: parsed.row.kind,
+ ref: parsed.row.ref,
+ payload: parsed.row.payload,
+ leaseUntil: parsed.row.leaseUntil,
+ memberKey: parsed.row.memberKey,
+ })
+ if (!reserved.ok) {
+ // Already ledgered — by this step's own earlier attempt, or (a module bug
+ // rather than a race) by another run that still holds the same target.
+ // Either way there is a live row for it and a second would be the double
+ // cleanup the unique key exists to prevent.
+ if (reserved.holder && reserved.holder.run_id !== run.id) {
+ out.rejected.push(`${parsed.row.kind} "${parsed.row.ref}" is already held by run ${reserved.holder.run_id}`)
+ }
+ continue
+ }
+ await resourcesDb.confirm(reserved.id)
+ out.recorded += 1
+ } catch (err) {
+ // Bookkeeping must not become the step's control flow.
+ out.rejected.push(err.message)
+ log.error('event resource insert failed', { run: run.id, step: step.id, message: err.message })
+ }
+ }
+
+ if (out.recorded > 0) await markRunDirty(run.id)
+
+ // The placeholder's job is over the moment the real rows exist. It is resolved
+ // even when the module reported nothing at all — an action that ledgers and
+ // then answers `ok` with an empty list is saying "I made nothing", and holding
+ // its placeholder open would make cleanup call `revert()` for a step that has
+ // nothing to give back on every terminal path for ever.
+ if (placeholderId) await resourcesDb.resolvePlaceholder(placeholderId)
+
+ return out
+}
+
+/**
+ * There is now something to clean up. Idempotent and guarded, so it can never
+ * walk a run back from `complete` or `incomplete` to `pending` — only a human's
+ * cleanup does that, and it does it deliberately.
+ */
+async function markRunDirty(runId) {
+ await runsDb.setCleanupStatus(runId, 'pending', ['not_required'])
+}
+
+module.exports = { ledgers, normalise, reserveStep, recordAnswer, markRunDirty, PLACEHOLDER_CLASSES }
diff --git a/server/src/model/events/eventRunControls.model.js b/server/src/model/events/eventRunControls.model.js
index d80a91a..a0ad08e 100644
--- a/server/src/model/events/eventRunControls.model.js
+++ b/server/src/model/events/eventRunControls.model.js
@@ -15,10 +15,13 @@
// diagnosis panel: a screen that explains why a phase has not started, beside a
// control that does something about it.
//
-// **One of §I's controls is still not here.** `cleanup` needs Phase 8's resource
-// ledger; there is nothing to revert, so cancel takes `{ reason }` and gains
-// `cleanup` when there is something for it to do. Absent rather than inert,
-// which is the posture Phase 1 set and every phase since has kept.
+// **`cleanup` is the eighth, and Phase 8 is what gave it a ledger to work over.**
+// It re-runs the teardown across every resource a run has not given back, and it
+// is `admin` where the other seven are `admin` + `moderator`: it is not incident
+// response, it is asking core to write to the world again. Its partner is
+// cancel's new `cleanup: false`, which is §L's "cancelling WITHOUT cleanup is a
+// separate, logged, admin-only action" — deliberately the flag that has to be
+// asked for, because the safe default is to give back what the run took.
//
// **Every control is guarded on the status it may act from, and the guard is a
// WHERE clause rather than a read-then-write.** A run console rendered thirty
@@ -37,6 +40,7 @@ const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const gatesDb = require('./eventPhaseGates.db')
+const resourcesDb = require('./eventRunResources.db')
const gates = require('../../events/gates')
const MAX_REASON = 500
@@ -152,11 +156,23 @@ async function resume(runId, options = {}, userId = null) {
* and a second writer on that row would race the process that owns it. It
* finishes into a cancelled run, which is honest.
*/
-async function cancel(runId, { reason } = {}, userId = null) {
+async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { isAdmin = true } = {}) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is already ${run.status}`)
+ // §L: cancelling WITHOUT cleanup is a separate, logged, ADMIN-only action. The
+ // route itself is `admin` + `moderator`, so the narrower gate cannot live in
+ // middleware — which of the two you have to be depends on what is in the body,
+ // exactly as the authoring role floor does (§K).
+ if (cleanup === false && !isAdmin) {
+ return {
+ ok: false,
+ status: 403,
+ errors: ['leaving a run\'s world changes in place is an administrator\'s decision'],
+ }
+ }
+
const note = clean(reason)
const from = ['scheduled', 'starting', 'running', 'paused', 'ending']
if (!(await runsDb.transition(run.id, from, 'cancelled', { error: note || 'cancelled by staff' }))) {
@@ -168,9 +184,83 @@ async function cancel(runId, { reason } = {}, userId = null) {
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
- detail: { from: run.status, to: 'cancelled', control: 'cancel', by: userId, reason: note, cancelledSteps: closed },
+ detail: {
+ from: run.status,
+ to: 'cancelled',
+ control: 'cancel',
+ by: userId,
+ reason: note,
+ cancelledSteps: closed,
+ cleanup: cleanup !== false,
+ },
})
- return { ok: true, run: await runsDb.getById(run.id), cancelledSteps: closed }
+
+ // **The teardown is not done here, and the request does not wait for it.**
+ // Cleanup is one leg of the runner's tick over terminal runs (§L), which is
+ // what makes it survive a process that dies halfway through it — and a cancel
+ // pressed at two in the morning must answer at once rather than after a dozen
+ // round trips to a shard that may be the reason it is being cancelled. The run
+ // is terminal the moment this returns, so the very next tick picks its ledger
+ // up.
+ //
+ // `cleanup: false` is the operator saying leave it. The resources stay
+ // unresolved and the run carries `incomplete`, which is the truthful value: the
+ // world changes are still up, they are listed on the console, and the log line
+ // above records who decided that.
+ let cleanupStatus = run.cleanup_status
+ if (cleanup === false && (await resourcesDb.unresolvedCount(run.id)) > 0) {
+ // `incomplete` is what takes the run out of the cleanup leg's scan, and it is
+ // the truthful value: the world changes are still up, they are listed on the
+ // console, and the log line above records who decided that.
+ //
+ // **The first draft spent every row's `revert_attempts` instead**, to stop the
+ // sweep by the same mechanism a failed retry does. It worked and it made the
+ // console lie: the run page rendered "3 attempts" beside resources nothing had
+ // ever tried, which reads as "core tried three times and could not". Found by
+ // opening the page. A counter that means two things is a counter a screen
+ // cannot render.
+ await runsDb.setCleanupStatus(run.id, 'incomplete')
+ cleanupStatus = 'incomplete'
+ }
+
+ return {
+ ok: true,
+ run: await runsDb.getById(run.id),
+ cancelledSteps: closed,
+ cleanup: cleanup !== false,
+ cleanupStatus,
+ }
+}
+
+/**
+ * Re-run cleanup over everything a run has not given back.
+ *
+ * The manual retry §L promises, and the only thing that clears
+ * `revert_attempts`. That licence is the same one a human's step retry has, and
+ * it is deliberately not extended to the automatic sweep: Engagement Phase 14's
+ * defect was exactly a sweep that reset every stale row, which made the attempt
+ * ceiling unreachable and left the row cycling for ever.
+ *
+ * Legal on a TERMINAL run only. A run still in flight has a ledger that is still
+ * growing, and reverting a resource the next step is about to use would be core
+ * undoing an event while it is happening.
+ */
+async function cleanupRun(runId, userId = null) {
+ const run = await loadRun(runId)
+ if (!run) return { ok: false, status: 404, errors: ['no such run'] }
+ if (!runsDb.TERMINAL.includes(run.status)) {
+ return conflict(`this run is still ${run.status}; cancel it before cleaning up after it`)
+ }
+ if (run.cleanup_status === 'not_required') {
+ return conflict('this run recorded no resources, so there is nothing to give back')
+ }
+
+ // eslint-disable-next-line global-require
+ const summary = await require('../../events/cleanup').cleanupRun(run, {
+ resetAttempts: true,
+ actor: userId,
+ })
+ return { ok: true, run: await runsDb.getById(run.id), summary }
}
/**
@@ -359,4 +449,4 @@ async function retryStep(runId, stepId, options = {}, userId = null) {
}
}
-module.exports = { pause, resume, cancel, advancePhase, confirmStep, skipStep, retryStep }
+module.exports = { pause, resume, cancel, cleanupRun, advancePhase, confirmStep, skipStep, retryStep }
diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js
index 4cb291e..6abb204 100644
--- a/server/src/model/events/eventRunLog.db.js
+++ b/server/src/model/events/eventRunLog.db.js
@@ -47,6 +47,16 @@ const KINDS = [
'run.budget', // the caps this run was seeded with, and which switch set each
'step.refused', // a step was not permitted: disabled, or over a cap
'version.verified', // a dry run passed against a version, unlocking scheduled starts
+ // Phase 8's six, and every one of them is an answer to "what did this event
+ // leave behind". `resource.recorded` is written at the ANSWER rather than at
+ // the placeholder, because a placeholder is a promise and the operator's
+ // question is about the world.
+ 'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
+ 'resource.orphaned', // a module reports a ledgered resource is no longer in force
+ 'cleanup.reverted', // a group of resources came back
+ 'cleanup.failed', // a group did not, with the reason and how it was left
+ 'cleanup.swept', // one pass over a run's ledger, and what it found
+ 'cleanup.retry', // a human cleared the attempt counter and asked again
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
diff --git a/server/src/model/events/eventRunResources.db.js b/server/src/model/events/eventRunResources.db.js
new file mode 100644
index 0000000..9f11af4
--- /dev/null
+++ b/server/src/model/events/eventRunResources.db.js
@@ -0,0 +1,353 @@
+// ── event_run_resources — SQL only ─────────────────────────────────────────
+//
+// EVENTS.md §D and §L ("The ledger's two rules"), and Phase 8 of EVENTS_PLAN.md.
+// Everything one run created or leased, and what became of it.
+//
+// **Rule 1 lives in `reserve()`.** A resource is recorded BEFORE it is
+// confirmed, so the placeholder this writes is the row that exists while the
+// dispatch is in flight — and the row that SURVIVES when the acknowledgement is
+// lost. Recording on the answer instead would make every object whose ack went
+// missing invisible to cleanup for ever.
+//
+// **Rule 2 lives in the status column and in `failRevert()`.** A revert that
+// never succeeds leaves its row unreverted, with the error on it, and the run
+// completes with `cleanup_status = 'incomplete'` rather than being held open.
+// Loud and sticky.
+//
+// **The unique key is enforced by the database, not by a read.** `reserve()`
+// answers `{ ok: false, code: 'held' }` on a duplicate key rather than checking
+// first and then inserting — two runs entering the same tick would both pass the
+// check. It is the argument `event_run_budget.spend()` makes about the cap and
+// `runsDb.transition` makes about a status, in the third place it applies.
+
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+// The one `kind` core owns. A module's kinds are opaque and stored verbatim; this
+// one is core's own, and `registries` refuses a module resource that claims it.
+const STEP_KIND = '@step'
+
+// The statuses that mean "core still believes this resource is this run's". They
+// are exactly the ones the `live_marker` generated column keeps non-NULL, so the
+// unique target key holds while a row is in one of them and releases when it
+// leaves. Duplicated here as a JavaScript list because the sweeps read by it too,
+// and a second copy that can drift is better than a query that cannot express it.
+const HELD = ['pending', 'confirmed', 'reverting']
+
+// Every status that still wants a human or a retry: `HELD` plus the two that mean
+// "we let go, and not cleanly". This is what "unreverted" means everywhere in
+// this feature — the console's list, `cleanup_status`, and the manual retry.
+const UNRESOLVED = [...HELD, 'orphaned', 'drifted']
+
+const COLUMNS = `id, run_id, step_id, owner_module, kind, ref, payload, lease_until,
+ status, revert_attempts, last_error, member_key, created_at, updated_at`
+
+// `payload` is opaque to core and stored verbatim, but it comes back as a string
+// from the driver and every caller wants the object — the cleanup sweep reads a
+// lease's baseline out of it, and the console renders it. Hydrated here for the
+// same reason a step's params are: one place rather than at each read.
+const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, null) }
+
+/**
+ * Record a resource that does not exist yet.
+ *
+ * Answers `{ ok: true, id }`, or `{ ok: false, code: 'held', holder }` when the
+ * target is already someone's — which is the lease conflict, surfaced as a
+ * refusal rather than a failure because nothing is wrong with the system: another
+ * run has the thing.
+ *
+ * **`ER_DUP_ENTRY` is the check.** The holder is looked up only to name it in the
+ * refusal, and only after the insert has already lost the race.
+ */
+async function reserve({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) {
+ try {
+ const result = await query(
+ `INSERT INTO event_run_resources
+ (run_id, step_id, owner_module, kind, ref, payload, lease_until, member_key, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
+ [runId, stepId, owner, kind, ref, payload === null ? null : JSON.stringify(payload), leaseUntil, memberKey],
+ )
+ return { ok: true, id: Number(result.insertId) }
+ } catch (err) {
+ if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
+ const [holder] = await query(
+ `SELECT run_id, status FROM event_run_resources
+ WHERE owner_module = ? AND kind = ? AND ref = ? AND status IN (?, ?, ?)
+ LIMIT 1`,
+ [owner, kind, ref, ...HELD],
+ )
+ return { ok: false, code: 'held', holder: holder || null }
+ }
+ throw err
+ }
+}
+
+/**
+ * Promote a reserved row to `confirmed`, optionally attaching what the module
+ * finally said about it.
+ *
+ * Guarded on `pending` so a late answer cannot un-revert a row cleanup has
+ * already dealt with — the same reason every other write in this feature is a
+ * compare-and-set rather than a read-then-write.
+ */
+async function confirm(id, { payload, leaseUntil, memberKey } = {}) {
+ const sets = ["status = 'confirmed'"]
+ const params = []
+ if (payload !== undefined) {
+ sets.push('payload = ?')
+ params.push(payload === null ? null : JSON.stringify(payload))
+ }
+ if (leaseUntil !== undefined) {
+ sets.push('lease_until = ?')
+ params.push(leaseUntil)
+ }
+ if (memberKey !== undefined) {
+ sets.push('member_key = ?')
+ params.push(memberKey)
+ }
+ const result = await query(
+ `UPDATE event_run_resources SET ${sets.join(', ')} WHERE id = ? AND status = 'pending'`,
+ [...params, id],
+ )
+ return (result.affectedRows || 0) > 0
+}
+
+/**
+ * Resolve a step placeholder once the module has named what it actually made.
+ *
+ * The placeholder's whole job is over at this point: the real rows exist, so the
+ * `@step` row must stop being one of the things cleanup will try to revert.
+ * `reverted` is the honest terminal state for it — there is nothing left to undo
+ * that the rows it stood in for do not now cover — and it releases the
+ * idempotency key for a later run, which matters because keys are per step and a
+ * re-materialised step reuses its own.
+ */
+async function resolvePlaceholder(id) {
+ const result = await query(
+ `UPDATE event_run_resources
+ SET status = 'reverted', last_error = NULL
+ WHERE id = ? AND kind = ? AND status IN ('pending', 'confirmed')`,
+ [id, STEP_KIND],
+ )
+ return (result.affectedRows || 0) > 0
+}
+
+/**
+ * One row by its target, live or not — how a caller that lost the insert race
+ * finds the row it meant to write. Newest first, so a target that has been held
+ * and released several times answers with the current holder.
+ */
+async function findByTarget(owner, kind, ref) {
+ const [row] = await query(
+ `SELECT ${COLUMNS} FROM event_run_resources
+ WHERE owner_module = ? AND kind = ? AND ref = ?
+ ORDER BY id DESC LIMIT 1`,
+ [owner, kind, ref],
+ )
+ return hydrate(row) || null
+}
+
+/** One run's whole ledger, oldest first — the console's read. */
+async function forRun(runId) {
+ const rows = await query(
+ `SELECT ${COLUMNS} FROM event_run_resources WHERE run_id = ? ORDER BY id`,
+ [runId],
+ )
+ return rows.map(hydrate)
+}
+
+/** The rows of one run that still want something: the cleanup sweep's input. */
+async function unresolvedForRun(runId, { maxAttempts = null } = {}) {
+ const params = [runId, ...UNRESOLVED]
+ const attemptClause = maxAttempts === null ? '' : ' AND revert_attempts < ?'
+ if (maxAttempts !== null) params.push(maxAttempts)
+ const rows = await query(
+ `SELECT ${COLUMNS} FROM event_run_resources
+ WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)${attemptClause}
+ ORDER BY id`,
+ params,
+ )
+ return rows.map(hydrate)
+}
+
+/** How many of one run's rows are still unresolved — what `cleanup_status` is derived from. */
+async function unresolvedCount(runId) {
+ const [row] = await query(
+ `SELECT COUNT(*) AS n FROM event_run_resources
+ WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
+ [runId, ...UNRESOLVED],
+ )
+ return Number(row?.n || 0)
+}
+
+/** Unresolved counts for several runs at once, keyed by run id — the run LIST's read. */
+async function unresolvedCounts(runIds) {
+ const ids = [...new Set(runIds || [])].filter(Boolean)
+ if (!ids.length) return new Map()
+ const rows = await query(
+ `SELECT run_id, COUNT(*) AS n FROM event_run_resources
+ WHERE run_id IN (${ids.map(() => '?').join(',')}) AND status IN (?, ?, ?, ?, ?)
+ GROUP BY run_id`,
+ [...ids, ...UNRESOLVED],
+ )
+ return new Map(rows.map((r) => [r.run_id, Number(r.n)]))
+}
+
+/**
+ * Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`.
+ *
+ * The compare-and-set that keeps the cleanup leg and the manual cleanup route off
+ * each other's rows. `reverting` is deliberately not claimable — a row another
+ * pass is mid-revert on is left alone, exactly as a step with a live claim is.
+ */
+async function claimRevert(id) {
+ const result = await query(
+ `UPDATE event_run_resources
+ SET status = 'reverting'
+ WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned', 'drifted')`,
+ [id],
+ )
+ return (result.affectedRows || 0) > 0
+}
+
+/** The revert worked. `reverted` is terminal and releases the target. */
+async function markReverted(id) {
+ await query(
+ `UPDATE event_run_resources SET status = 'reverted', last_error = NULL WHERE id = ?`,
+ [id],
+ )
+}
+
+/**
+ * The revert did not work, and the row goes back to being unresolved.
+ *
+ * `revert_attempts` is incremented here and NOWHERE else, and it is never reset by
+ * a sweep — Engagement Phase 14's rule, whose defect was a reclaim that returned
+ * every stale row to its start state and made the attempt ceiling unreachable, so
+ * the row cycled for ever and was never eligible for any retention sweep. The one
+ * thing that may reset it is a human pressing cleanup, which is the same licence
+ * a human's step retry has.
+ *
+ * `restoreTo` is where the row lands: `drifted` when the module says somebody else
+ * moved the value, `orphaned` when it says the thing is gone, and `confirmed`
+ * otherwise — still ours, still out there, try again.
+ */
+async function failRevert(id, error, restoreTo = 'confirmed') {
+ await query(
+ `UPDATE event_run_resources
+ SET status = ?, revert_attempts = revert_attempts + 1, last_error = ?
+ WHERE id = ?`,
+ [restoreTo, String(error || 'the revert did not answer').slice(0, 500), id],
+ )
+}
+
+/**
+ * A human is trying again: clear the attempt counter on one run's unresolved rows.
+ *
+ * Only ever called from the cleanup route with an actor behind it. The automatic
+ * leg must never do this (see `failRevert`).
+ */
+async function resetAttempts(runId) {
+ const result = await query(
+ `UPDATE event_run_resources
+ SET revert_attempts = 0
+ WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
+ [runId, ...UNRESOLVED],
+ )
+ return result.affectedRows || 0
+}
+
+/** Every live row one module owns, for the reconcile sweep. */
+async function liveForModule(owner, { limit = 500 } = {}) {
+ const rows = await query(
+ `SELECT ${COLUMNS} FROM event_run_resources
+ WHERE owner_module = ? AND status IN ('pending', 'confirmed')
+ ORDER BY id LIMIT ?`,
+ [owner, Number(limit)],
+ )
+ return rows.map(hydrate)
+}
+
+/** Every module that currently owns a live row — who the reconcile sweep asks. */
+async function modulesWithLiveRows() {
+ const rows = await query(
+ `SELECT DISTINCT owner_module FROM event_run_resources
+ WHERE status IN ('pending', 'confirmed')`,
+ )
+ return rows.map((r) => r.owner_module)
+}
+
+/**
+ * The game no longer has it. Never reached by a revert — a revert that finds
+ * nothing there is a SUCCESS (§L, and it is what a Rust wipe needs) — only by
+ * reconcile, which is a different question: nobody asked for this to go.
+ */
+async function markOrphaned(id, detail = null) {
+ await query(
+ `UPDATE event_run_resources
+ SET status = 'orphaned', last_error = ?
+ WHERE id = ? AND status IN ('pending', 'confirmed', 'reverting')`,
+ [detail === null ? null : String(detail).slice(0, 500), id],
+ )
+}
+
+/**
+ * Terminal runs that still owe the world something — the cleanup leg's scan.
+ *
+ * **Both halves of the WHERE were live-walk findings, and they are opposite
+ * mistakes.**
+ *
+ * `cleanup_status = 'pending'` alone missed a run whose only resource was a
+ * LEASE: `core.lease` reserves its own row and never goes through the ledger's
+ * `markRunDirty`, so the flag stayed `not_required` and the lease was never given
+ * back at all. Hence `not_required` is in the list — a terminal run with an
+ * unresolved row has something to do whatever any summary column says, and
+ * treating that combination as work is the fail-safe direction.
+ *
+ * And the run status filter alone made `MAX_REVERT_ATTEMPTS` mean ONE attempt,
+ * because the first failing sweep set `incomplete` and nothing looked at the run
+ * again. That is fixed in `cleanupRun`, which now only writes `incomplete` once
+ * there is nothing left it will try — so `incomplete` genuinely means "finished
+ * with, and not finished", which is exactly what excludes both a run whose
+ * retries are spent and a run an admin cancelled without cleanup.
+ *
+ * The attempt bound is in the join for a different reason: without it a run whose
+ * rows are all spent would be selected, worked over and found to have nothing to
+ * do on every tick for the rest of its life.
+ */
+async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
+ return query(
+ `SELECT DISTINCT r.id, r.status, r.cleanup_status, r.version_id, r.definition_id, r.scope
+ FROM event_runs r
+ JOIN event_run_resources res ON res.run_id = r.id
+ WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
+ AND r.cleanup_status IN ('pending', 'not_required')
+ AND res.status IN (?, ?, ?, ?, ?)
+ AND res.revert_attempts < ?
+ ORDER BY r.id
+ LIMIT ?`,
+ [...UNRESOLVED, Number(maxAttempts), Number(limit)],
+ )
+}
+
+module.exports = {
+ STEP_KIND,
+ HELD,
+ UNRESOLVED,
+ reserve,
+ confirm,
+ resolvePlaceholder,
+ findByTarget,
+ forRun,
+ unresolvedForRun,
+ unresolvedCount,
+ unresolvedCounts,
+ claimRevert,
+ markReverted,
+ failRevert,
+ resetAttempts,
+ liveForModule,
+ modulesWithLiveRows,
+ markOrphaned,
+ runsNeedingCleanup,
+}
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index 5f09f13..5b224ff 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -386,6 +386,32 @@ async function setHealth(id, health) {
return Number(result?.affectedRows || 0) === 1
}
+/**
+ * Set `cleanup_status`, optionally guarded on where it is now (Phase 8).
+ *
+ * Four values and three writers, which is why the guard is a parameter rather
+ * than baked in. The ledger stamps `pending` the first time a run records
+ * anything, and it must do so only over `not_required` — a run already marked
+ * `complete` must not be walked back to `pending` by a late resource, and a
+ * `incomplete` one must not be silently tidied. The cleanup sweep sets `complete`
+ * or `incomplete` from what it found, unguarded, because the sweep IS the
+ * authority on that. A human's cleanup route re-opens `pending` deliberately, and
+ * says so in the log with the actor.
+ *
+ * **`pending` on a run that is still running is not a bug and reads correctly**:
+ * there is something to clean up and it has not happened yet. The alternative -
+ * a fifth value meaning "there will be something later" - is a state nothing
+ * would ever branch on.
+ */
+async function setCleanupStatus(id, to, from = null) {
+ const guard = from === null ? '' : ` AND cleanup_status IN (${from.map(() => '?').join(',')})`
+ const result = await query(
+ `UPDATE event_runs SET cleanup_status = ? WHERE id = ?${guard}`,
+ from === null ? [to, id] : [to, id, ...from],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
/**
* Runs whose start instant passed more than their own grace window ago (§E, §L).
*
@@ -488,6 +514,7 @@ module.exports = {
statusOf,
transition,
setHealth,
+ setCleanupStatus,
concurrencyHolder,
reclaimStale,
terminalBefore,
diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js
index 5a41f57..dd00149 100644
--- a/server/src/model/events/eventRuns.model.js
+++ b/server/src/model/events/eventRuns.model.js
@@ -27,6 +27,7 @@ const definitionsDb = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const settingsDb = require('./eventActionSettings.db')
const budgetDb = require('./eventRunBudget.db')
+const resourcesDb = require('./eventRunResources.db')
const authorize = require('../../events/authorize')
const MAX_SCOPE = 190
@@ -204,11 +205,12 @@ async function create(
async function detail(runId) {
const run = await db.getById(runId)
if (!run) return null
- const [steps, counts, gateRows, budget] = await Promise.all([
+ const [steps, counts, gateRows, budget, resources] = await Promise.all([
stepsDb.listForRun(runId),
stepsDb.statusCounts(runId),
gatesDb.listForRun(runId),
budgetDb.forRun(runId),
+ resourcesDb.forRun(runId),
])
const now = new Date()
return {
@@ -226,6 +228,37 @@ async function detail(runId) {
cap: b.cap,
from: b.effective_from,
})),
+ // What this run changed in the world, and what became of it (Phase 8). The
+ // WHOLE ledger, reverted rows included, because "what did last night's
+ // invasion actually spawn, and did all of it come back" is the question this
+ // panel exists for and a list of only the failures cannot answer the second
+ // half of it.
+ //
+ // **The `@step` placeholders are filtered out.** They are core's own
+ // bookkeeping — a row that says "a dispatch is in flight and may have made
+ // something" — and the console's list is of things in the world. One left in
+ // would read as a resource nobody can name, which is exactly the confusion it
+ // exists to prevent internally.
+ resources: resources
+ .filter((r) => r.kind !== resourcesDb.STEP_KIND)
+ .map((r) => ({
+ id: r.id,
+ stepId: r.step_id,
+ module: r.owner_module,
+ kind: r.kind,
+ ref: r.ref,
+ payload: r.payload,
+ leaseUntil: r.lease_until,
+ status: r.status,
+ revertAttempts: r.revert_attempts,
+ lastError: r.last_error,
+ memberKey: r.member_key,
+ createdAt: r.created_at,
+ })),
+ // How many rows are still unresolved, counted over the WHOLE ledger rather
+ // than over the list above — a placeholder left standing by a lost
+ // acknowledgement is exactly the case `cleanup_status` must not call clean.
+ unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length,
}
}
diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js
index df0024d..7503186 100644
--- a/server/src/modules/loader.js
+++ b/server/src/modules/loader.js
@@ -228,6 +228,39 @@ function buildCtx(id, moduleRoot) {
emit: (triggerId, envelope) => {
engagementEmit.emit(id, triggerId, envelope)
},
+ // EVENTS.md §L, and the resource ledger (Phase 8). "On reconnect the runner
+ // asks each ledgered resource's module to reconcile" — and this is how the
+ // runner learns there has BEEN a reconnect.
+ //
+ // **Core cannot decide when to call this, and that is the contract rather
+ // than a gap.** §F: core has no concept of the game being up, because a
+ // module with six sidecars cannot answer that question in the singular. So
+ // the module says so, when it sees its own — module-uo already watches
+ // `bootId` to tell a shard restart from a sidecar reconnect, which is
+ // exactly the moment a ledger of live spawns has become a claim about a
+ // world that no longer exists.
+ //
+ // `id` is bound here and never taken from the arguments, like `emit` and
+ // `teams.activity.push` before it: a module reconciles its OWN ledger, and
+ // without the binding this would be a way to have core mark another
+ // module's resources orphaned.
+ //
+ // Fire-and-forget and returns undefined, for the third time and the same
+ // reason: this is called from inside a connection handler, and there is
+ // nothing a module could correctly do with a failure of core's bookkeeping.
+ reconcile: () => {
+ // eslint-disable-next-line global-require
+ require('../events/cleanup')
+ .reconcileModule(id)
+ .then(
+ (summary) => {
+ if (summary && summary.orphaned) {
+ log.warn('event resources orphaned on reconcile', { module: id, ...summary })
+ }
+ },
+ (err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
+ )
+ },
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. Live from Phase 7; it threw until the `user_notifications` table
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
index 2823c52..88d52e8 100644
--- a/server/src/modules/registries.js
+++ b/server/src/modules/registries.js
@@ -157,11 +157,11 @@ const eventBudgets = new Map()
// lease id → { owner, id, label, type, min, max, maxDurationMs, description,
// read, apply, restore } (§F "Leases: one more declaration", Phase 7).
//
-// **Phase 7 registers a lease and nothing acquires one.** Core owns the duration
-// and the conflict check, the module owns reading the current value and writing a
-// new one — and both halves of that live in the resource ledger, which is Phase
-// 8's. What is here is the declaration, its validation and its catalog entry, so
-// that the module contract is one version rather than two.
+// **Core owns the duration and the conflict check; the module owns reading the
+// current value and writing a new one.** Phase 7 registered a lease and nothing
+// acquired one; Phase 8 gave it a verb — `core.lease`, a CORE action, so the
+// bound and the two-events-one-target refusal are enforced in one place rather
+// than re-implemented by every module that ships a lease.
const eventLeases = new Map()
// source id → { owner, id, label, description, resolve } (§F "Param option
@@ -435,14 +435,14 @@ async function resolveAudience(id, params = {}) {
/**
* Every declaration WITHOUT its callables — what the admin catalog serves.
*
- * `perform`, `revert` and `cost` are stripped for the same reason `resolve` is
+ * `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
* stripped from an audience and `handler` from a slash command: this is the
* object that leaves the process, and the browser's whole relationship with an
* action is naming one by id. §F's "a module registers actions server-side and
* adds no routes for them" is only true if the functions never ride out.
*/
const allEventActions = () =>
- [...eventActions.values()].map(({ perform, revert, cost, ...rest }) => rest)
+ [...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
/** One declaration, callables included. The runner's lookup (Phase 2). */
const eventAction = (id) => eventActions.get(id) || null
@@ -485,7 +485,7 @@ const isEventBudget = (id) => eventBudgets.has(id)
const allEventLeases = () =>
[...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest)
-/** One lease, callables included. Phase 8's lookup; nothing calls it yet. */
+/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
const eventLease = (id) => eventLeases.get(id) || null
/** Every option source WITHOUT its resolver — the authoring form's list. */
@@ -985,7 +985,7 @@ function checkActionParam(actionId, entry, seen) {
}
/**
- * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`.
+ * `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
*
* A typed verb core may ask a registrant to carry out. Everything decidable from
* the argument alone is decided here, at the call; the collision — is this id
@@ -1042,6 +1042,23 @@ function checkEventActionShape(entry) {
`registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
)
}
+ // §L's reconnect row, and it is OPTIONAL where `revert` is required (Phase 8).
+ // `revert` is how a run gives a resource back; `reconcile` is how a module says
+ // which of them the game still has after something outside core restarted. A
+ // module that cannot answer that question is not broken -- core simply keeps
+ // believing its own ledger, which is the pre-Phase-8 behaviour -- whereas a
+ // module that created something and cannot undo it has made a promise core has
+ // no way to keep. Only meaningful for an action that ledgers anything.
+ if (a.reconcile !== undefined) {
+ if (typeof a.reconcile !== 'function') {
+ throw new Error(`registerEventActions: ${a.id} reconcile must be a function`)
+ }
+ if (a.reversible === 'none' || a.reversible === 'self') {
+ throw new Error(
+ `registerEventActions: ${a.id} declares reconcile() but is reversible: '${a.reversible}' and ledgers nothing`,
+ )
+ }
+ }
if (a.cost !== undefined && typeof a.cost !== 'function') {
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
}
@@ -1076,6 +1093,7 @@ function checkEventActionShape(entry) {
cost: a.cost || null,
perform: a.perform,
revert: a.revert || null,
+ reconcile: a.reconcile || null,
}
}
@@ -1130,10 +1148,12 @@ function checkEventBudgetShape(entry) {
* thing a module must not be allowed to skip. A lease whose restore writes blindly
* is a lease that silently reverts an operator's manual fix.
*
- * **Nothing acquires a lease in Phase 7.** This registers, validates and serves
- * one; the ledger that holds it, the deadline that goes down the wire and the
- * drift answer are Phase 8's. Declaring it now is what keeps the module contract
- * one version rather than two.
+ * **A lease is acquired by `core.lease` and by nothing else** (Phase 8). The step
+ * names a lease id, a value and a duration; core reads the baseline, reserves the
+ * target in `event_run_resources` — which is where the two-events-one-target
+ * refusal comes from — applies the value with the deadline, and restores it at
+ * teardown through the same `restore()` the drift check lives in. A module ships
+ * the three callables and never has to own any of that.
*/
function checkEventLeaseShape(entry) {
const l = entry || {}
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 009a46b..6442dce 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -9,13 +9,12 @@
// this screen does.
//
// **Phase 3 added the live run controls** at the bottom of this file: pause,
-// resume, cancel, and a step's confirm, skip and retry. What is still absent is
-// `advance`, `cleanup` and the action switchboard — `advance` has no honest
-// meaning until Phase 5 gives a phase an advance condition, `cleanup` has no
-// ledger to work over until Phase 8, and the switchboard is Phase 6's. Each of
-// them is absent rather than stubbed, for the reason the whole set was in Phase
-// 1: a control that returns 200 and does nothing is worse than one that is not
-// there.
+// resume, cancel, and a step's confirm, skip and retry. `advance` joined them in
+// Phase 5, the action switchboard in Phase 6, and **`cleanup` in Phase 8** —
+// each when the phase that gave it something to act on landed, and each absent
+// rather than stubbed until then, for the reason the whole set was in Phase 1: a
+// control that returns 200 and does nothing is worse than one that is not there.
+// Nothing in the § API surface table is absent any more.
const registries = require('../../../modules/registries')
const spec = require('../../../events/spec')
@@ -315,6 +314,12 @@ exports.getRun = async (req, res) => {
// rather than "what is allowed now" — which is the question that survives
// an admin moving a switch tomorrow.
budget: found.budget,
+ // The resource ledger (Phase 8): everything this run created or borrowed, and
+ // what became of each. The WHOLE ledger, reverted rows included — "how much
+ // did last night's invasion spawn, and did all of it come back" is one
+ // question with two halves, and a list of only the failures answers neither.
+ resources: found.resources,
+ unresolvedResources: found.unresolvedResources,
})
}
@@ -678,14 +683,48 @@ exports.advanceRunPhase = async (req, res) => {
exports.cancelRun = async (req, res) => {
const runId = asId(req.params.runId)
if (!runId) return res.status(400).json({ error: 'bad run id' })
- const result = await controls.cancel(runId, { reason: req.body?.reason }, req.user.id)
+ // **`cleanup` defaults to true and has to be asked out of.** §L makes cancelling
+ // WITHOUT cleanup the separate, admin-only, logged action, so an absent flag
+ // must mean "give back what this run took" — the safe direction, and the one a
+ // moderator's cancel at two in the morning takes without having to know the
+ // flag exists.
+ const withCleanup = req.body?.cleanup !== false
+ const result = await controls.cancel(
+ runId,
+ { reason: req.body?.reason, cleanup: withCleanup },
+ req.user.id,
+ { isAdmin: req.user.role === 'admin' },
+ )
if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
await activity.log({
req,
action: 'event.run.cancelled',
- detail: { runId, reason: req.body?.reason || null, cancelledSteps: result.cancelledSteps },
+ detail: {
+ runId,
+ reason: req.body?.reason || null,
+ cancelledSteps: result.cancelledSteps,
+ cleanup: result.cleanup,
+ },
})
- return res.json({ run: shapeRun(result.run), cancelledSteps: result.cancelledSteps })
+ return res.json({
+ run: shapeRun(result.run),
+ cancelledSteps: result.cancelledSteps,
+ cleanup: result.cleanup,
+ })
+}
+
+/** POST /api/v1/admin/events/runs/:runId/cleanup */
+exports.cleanupRun = async (req, res) => {
+ const runId = asId(req.params.runId)
+ if (!runId) return res.status(400).json({ error: 'bad run id' })
+ const result = await controls.cleanupRun(runId, req.user.id)
+ if (!result.ok) return res.status(result.status || 409).json({ errors: result.errors })
+ await activity.log({ req, action: 'event.run.cleaned', detail: { runId, ...result.summary } })
+ // **A 200 whatever the sweep found.** The request succeeded; some resources may
+ // still be out there, and answering 4xx would make "the shard refused to delete
+ // three of these" indistinguishable from "you sent a bad run id" — the same
+ // argument the dry run's findings make.
+ return res.json({ run: shapeRun(result.run), summary: result.summary })
}
/** POST /api/v1/admin/events/runs/:runId/steps/:stepId/confirm */
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index f75fbc9..034f88b 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -17,8 +17,10 @@
// 5; **`verify` and the action switchboard arrived in Phase 6** — `verify` at
// `admin, editor` because a dry run dispatches nothing, and both halves of
// `/actions` at `admin`, because §K puts the switchboard in the same row as the
-// world-changing actions it governs. `cleanup` is still absent rather than
-// stubbed: there is no resource ledger until Phase 8.
+// world-changing actions it governs. **`cleanup` completed the set in Phase 8**,
+// and it is `admin` rather than admin+moderator for the same §K reason: it asks
+// core to write to the world again, which is not incident response. There is no
+// route in the § API surface table left absent.
//
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
// `/calendar` and `/runs` are never read as an event id.
@@ -207,9 +209,9 @@ eventsRouter.get(
'/runs/:runId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
- // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder's own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human.'
+ // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder's own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- /* #swagger.responses[200] = { description: 'The run, its steps, the status counts and the phase gates', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter and the resource ledger', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" } } } } } } */
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.getRun,
)
@@ -273,16 +275,29 @@ eventsRouter.post(
'/runs/:runId/cancel',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Cancel a run'
- // #swagger.description = 'Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.'
+ // #swagger.description = 'Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` arrived in Phase 8 and DEFAULTS TO TRUE: what the run created or borrowed is given back by the runner cleanup leg on its next tick, which is why this answers at once rather than after a round trip per resource. Sending `cleanup: false` deliberately leaves the world changes in place — that is admin-only even though the route is admin+moderator, because which of the two you have to be depends on what is in the body — and the run then carries `cleanup_status: incomplete` with every unreverted row listed on its console.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
- /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Why. Recorded on the run and in its log, with the actor." } } } } } } */
- /* #swagger.responses[200] = { description: 'The cancelled run and how many steps were closed out with it', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, cancelledSteps: { type: "integer" } } } } } } */
+ /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { reason: { type: "string", description: "Why. Recorded on the run and in its log, with the actor." }, cleanup: { type: "boolean", description: "Default true. False leaves the world changes from this run in place, and is admin-only." } } } } } } */
+ /* #swagger.responses[200] = { description: 'The cancelled run, how many steps were closed out with it, and whether cleanup was asked for', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, cancelledSteps: { type: "integer" }, cleanup: { type: "boolean" } } } } } } */
/* #swagger.responses[409] = { description: 'The run has already reached a terminal status', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
- /* #swagger.responses[403] = { description: 'Not an admin or moderator', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or moderator, or a moderator asking to skip cleanup', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
liveControl,
controller.cancelRun,
)
+eventsRouter.post(
+ '/runs/:runId/cleanup',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Re-run cleanup over everything this run has not given back'
+ // #swagger.description = 'The manual retry EVENTS.md §L promises, and the only thing that clears a resource attempt counter — the automatic sweep never does, because a sweep that reset every stale row is what made an attempt ceiling unreachable in the engagement workstream. Legal on a TERMINAL run only: a run still in flight has a ledger that is still growing, and reverting a resource the next step is about to use would be core undoing an event while it is happening. `admin` rather than admin+moderator, unlike the seven live controls beside it, because this is not incident response — it asks core to write to the world again, which §K puts in the same row as the world-changing actions themselves. Answers 200 whatever it found: some resources may still be out there, and a 4xx would make that indistinguishable from a bad run id.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'The run and what the sweep managed', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, summary: { type: "object", properties: { attempted: { type: "integer" }, reverted: { type: "integer" }, drifted: { type: "integer" }, failed: { type: "integer" }, remaining: { type: "integer" } } } } } } } } */
+ /* #swagger.responses[409] = { description: 'The run is still in flight, or recorded no resources at all', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOnly,
+ controller.cleanupRun,
+)
+
eventsRouter.post(
'/runs/:runId/advance',
// #swagger.tags = ['Admin · Events']
diff --git a/server/src/server.js b/server/src/server.js
index be12168..a297cc1 100644
--- a/server/src/server.js
+++ b/server/src/server.js
@@ -16,6 +16,7 @@ const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
const teamDigestWorker = require('./utils/teamDigestWorker')
const engagementWorker = require('./utils/engagementWorker')
const eventRunner = require('./utils/eventRunner')
+const eventCleanup = require('./events/cleanup')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -170,11 +171,25 @@ async function start() {
// enables a rule: core seeds none and `enabled` defaults to 0.
engagementWorker.start()
- // Advance scheduled events (EVENTS.md §E). Materialise, advance, drain, and the
- // run-log retention sweep. No-op until an admin publishes a definition and
- // starts a run: core ships no event definitions.
+ // Advance scheduled events (EVENTS.md §E). Materialise, advance, drain, clean
+ // up and the run-log retention sweep. No-op until an admin publishes a
+ // definition and starts a run: core ships no event definitions.
eventRunner.start()
+ // **Core's own restart is the one reconnect core can see** (EVENTS.md §L,
+ // Phase 8). Every other one belongs to a module, which reports it through
+ // `ctx.events.reconcile()`; this is the case where the thing that restarted was
+ // this process, and the ledger it wakes up holding may describe a world that
+ // moved on while it was down. Awaited by nobody and never fatal: a module that
+ // cannot answer leaves its rows alone, which is the pre-Phase-8 behaviour.
+ eventCleanup
+ .reconcileAll()
+ .then((summaries) => {
+ const orphaned = Object.values(summaries).reduce((n, x) => n + (x.orphaned || 0), 0)
+ if (orphaned) log.warn('event resources orphaned at boot', { orphaned, summaries })
+ })
+ .catch((err) => log.error('boot reconcile failed', { message: err.message }))
+
setupShutdown(server, internalServer)
}
diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js
index d4ef550..deb55ec 100644
--- a/server/src/utils/eventRunner.js
+++ b/server/src/utils/eventRunner.js
@@ -10,7 +10,16 @@
// 1. **reclaim** — release leases whose holder died, never touching `attempts`
// 2. **materialise** — sweep occurrences past their grace window into `missed`
// 3. **advance** — claim each due run and move it through its phases
-// 4. **prune** — the `event_run_log` retention sweep, on its own long clock
+// 4. **cleanup** — give back what a TERMINAL run still holds (Phase 8)
+// 5. **prune** — the `event_run_log` retention sweep, on its own long clock
+//
+// **Cleanup is a leg rather than a limb of `advanceRun`**, and its position in
+// that list is load-bearing: it runs after advance, so a run that completes in
+// one tick is torn down in the same one, and it is one place rather than four, so
+// a process that dies mid-teardown resumes on the next tick instead of leaving a
+// world half-restored with nothing scheduled to finish it. §L's "cleanup steps
+// are generated from the ledger and run" on cancellation and abort as well as on
+// completion is one query here rather than a hook on each of the three.
//
// **What a phase advances on, as of Phase 5.** Every step terminal, and — if the
// phase authored one — its GATE open as well. The gate is an ADDITIONAL
@@ -69,6 +78,8 @@ const gates = require('../events/gates')
const spec = require('../events/spec')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
+const ledger = require('../events/ledger')
+const cleanup = require('../events/cleanup')
const authorize = require('../events/authorize')
const log = require('./logger')('event-runner')
@@ -261,6 +272,27 @@ async function drainStep(run, step, now, carry = {}) {
}
}
+ // ── Rule 1: record BEFORE the dispatch, not after ──
+ //
+ // §D. A step that ledgers gets a placeholder keyed by its idempotency key,
+ // written before anything reaches the module, so an answer that never comes
+ // back still leaves cleanup something to act on. Recording afterwards would
+ // make every object whose acknowledgement was lost invisible for ever, which is
+ // the one failure the whole world-write half cannot tolerate.
+ //
+ // It is deliberately AFTER the permission check: a refused step never reaches
+ // the module, so it has created nothing and must ledger nothing.
+ let placeholderId = null
+ try {
+ placeholderId = await ledger.reserveStep(run, step, action)
+ } catch (err) {
+ // The ledger is what makes a world write recoverable, so a step that cannot
+ // be recorded must not be dispatched. Transient — the next attempt tries the
+ // insert again — because the alternative is an unrecorded world change.
+ log.error('could not reserve the ledger row', { run: run.id, step: step.id, message: err.message })
+ return applyFailure(run, step, `the resource ledger could not record this step: ${err.message}`)
+ }
+
const result = await dispatchStep(step, { run })
if (result.actionVersionDrift) {
@@ -273,6 +305,36 @@ async function drainStep(run, step, now, carry = {}) {
})
}
+ // ── …and promote it on the answer ──
+ //
+ // On both success shapes, because `await: 'human'` is a SUCCESS: the module did
+ // its part and something outside the system has to happen next, and a cue's
+ // confirm finishes the step without a second dispatch — so this is the only
+ // moment its resources can be recorded. A failure records nothing and leaves the
+ // placeholder standing, which is the whole point of writing one.
+ if (result.outcome === 'done' || result.outcome === 'parked') {
+ const recorded = await ledger.recordAnswer({
+ run,
+ step,
+ action,
+ placeholderId,
+ resources: result.resources,
+ })
+ if (recorded.recorded > 0 || recorded.rejected.length > 0) {
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'resource.recorded',
+ phase: step.phase,
+ detail: {
+ action: step.action_id,
+ recorded: recorded.recorded,
+ ...(recorded.rejected.length ? { rejected: recorded.rejected } : {}),
+ },
+ })
+ }
+ }
+
if (result.outcome === 'parked') {
// The GM cue. The step stays `running` with a NULL lease: genuinely in
// flight, nothing holding it, so the stale reclaim passes it by and a cue
@@ -459,9 +521,11 @@ async function advanceRun(run, now) {
}
if (run.status === 'ending') {
- // A run that reached the wind-down and then lost its process. Phase 8 puts
- // cleanup here; until then `ending` is a state a run passes through rather
- // than one it does work in, and completing it is the whole recovery.
+ // A run that reached the wind-down and then lost its process. Completing it is
+ // still the whole recovery even with a ledger in the picture: cleanup is a leg
+ // of the tick over TERMINAL runs, so the row this leaves behind is exactly
+ // what that leg is looking for, and doing the teardown here as well would be
+ // the second call site the leg exists to avoid.
await runsDb.transition(run.id, 'ending', 'completed')
await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } })
return 'completed'
@@ -536,8 +600,9 @@ async function advanceRun(run, now) {
const next = phases[phaseIndex + 1]
if (!next) {
// §E's `ending` exists for the reason `sending` does in the outbox — it is
- // what a claim sets — so the run passes through it even though Phase 2 has
- // no cleanup to do there. Phase 8 is what gives it work.
+ // what a claim sets. The run still passes straight through it: the ledger's
+ // teardown is the tick's cleanup leg, which runs after this one in the same
+ // tick and takes the run as it now is.
if (!(await runsDb.transition(run.id, 'running', 'ending'))) return 'taken'
await logDb.write({ runId: run.id, kind: 'run.status', phase: phaseKey, detail: { from: 'running', to: 'ending' } })
await runsDb.transition(run.id, 'ending', 'completed')
@@ -790,6 +855,17 @@ async function tick(now = new Date()) {
}
if (due && due.length) log.info('event runs swept', { due: due.length, ...counts })
+ // **After advance, deliberately.** A run that reached `completed` two lines ago
+ // is torn down in this tick rather than the next, so §L's "a run reaches
+ // `completed` with `cleanup_status = 'incomplete'`" is what an operator sees
+ // instead of a completed run that briefly claims it has cleanup pending.
+ try {
+ const swept = await cleanup.sweep()
+ if (swept) log.info('event cleanup swept', { runs: swept })
+ } catch (err) {
+ log.error('cleanup sweep failed', { message: err.message })
+ }
+
try {
await prune(now)
} catch (err) {
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 4210995..24c5fac 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -4137,7 +4137,7 @@
],
"responses": {
"200": {
- "description": "The run, its steps, the status counts and the phase gates",
+ "description": "The run, its steps, the status counts, the phase gates, the cap meter and the resource ledger",
"content": {
"application/json": {
"schema": {
@@ -4164,6 +4164,23 @@
"type": "object",
"additionalProperties": true
}
+ },
+ "budget": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "resources": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "unresolvedResources": {
+ "type": "integer"
}
}
}
@@ -4295,7 +4312,7 @@
"Admin · Events"
],
"summary": "Cancel a run",
- "description": "Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.",
+ "description": "Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` arrived in Phase 8 and DEFAULTS TO TRUE: what the run created or borrowed is given back by the runner cleanup leg on its next tick, which is why this answers at once rather than after a round trip per resource. Sending `cleanup: false` deliberately leaves the world changes in place — that is admin-only even though the route is admin+moderator, because which of the two you have to be depends on what is in the body — and the run then carries `cleanup_status: incomplete` with every unreverted row listed on its console.",
"parameters": [
{
"name": "runId",
@@ -4308,7 +4325,7 @@
],
"responses": {
"200": {
- "description": "The cancelled run and how many steps were closed out with it",
+ "description": "The cancelled run, how many steps were closed out with it, and whether cleanup was asked for",
"content": {
"application/json": {
"schema": {
@@ -4320,6 +4337,9 @@
},
"cancelledSteps": {
"type": "integer"
+ },
+ "cleanup": {
+ "type": "boolean"
}
}
}
@@ -4330,7 +4350,7 @@
"description": "Bad Request"
},
"403": {
- "description": "Not an admin or moderator",
+ "description": "Not an admin or moderator, or a moderator asking to skip cleanup",
"content": {
"application/json": {
"schema": {
@@ -4376,6 +4396,10 @@
"reason": {
"type": "string",
"description": "Why. Recorded on the run and in its log, with the actor."
+ },
+ "cleanup": {
+ "type": "boolean",
+ "description": "Default true. False leaves the world changes from this run in place, and is admin-only."
}
}
}
@@ -4384,6 +4408,102 @@
}
}
},
+ "/api/v1/admin/events/runs/{runId}/cleanup": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Re-run cleanup over everything this run has not given back",
+ "description": "The manual retry EVENTS.md §L promises, and the only thing that clears a resource attempt counter — the automatic sweep never does, because a sweep that reset every stale row is what made an attempt ceiling unreachable in the engagement workstream. Legal on a TERMINAL run only: a run still in flight has a ledger that is still growing, and reverting a resource the next step is about to use would be core undoing an event while it is happening. `admin` rather than admin+moderator, unlike the seven live controls beside it, because this is not incident response — it asks core to write to the world again, which §K puts in the same row as the world-changing actions themselves. Answers 200 whatever it found: some resources may still be out there, and a 4xx would make that indistinguishable from a bad run id.",
+ "parameters": [
+ {
+ "name": "runId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The run and what the sweep managed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "run": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "summary": {
+ "type": "object",
+ "properties": {
+ "attempted": {
+ "type": "integer"
+ },
+ "reverted": {
+ "type": "integer"
+ },
+ "drifted": {
+ "type": "integer"
+ },
+ "failed": {
+ "type": "integer"
+ },
+ "remaining": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Not an admin",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "The run is still in flight, or recorded no resources at all",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "errors": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/events/runs/{runId}/log": {
"get": {
"tags": [
diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js
index a2d4148..cbf9e16 100644
--- a/server/test/eventActionRegistry.test.js
+++ b/server/test/eventActionRegistry.test.js
@@ -44,10 +44,15 @@ const register = (owner, entries) => {
registries.apply(api.staged)
}
-test('core registers its three actions on every boot', () => {
+test('core registers its four actions on every boot', () => {
registries.registerCore()
const ids = registries.allEventActions().map((a) => a.id)
- assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
+ // `core.lease` joined the three in Phase 8, and it is the only one of the four
+ // that genuinely changes the world — which is why it is core's rather than each
+ // module's: §F puts the duration bound and the two-events-one-target conflict
+ // check on core's side of the seam, and a lease verb per module would be that
+ // bound re-implemented once per module and advisory everywhere.
+ assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue', 'core.lease'])
assert.equal(ids.length, coreEventActions.ACTIONS.length)
})
@@ -56,6 +61,7 @@ test('the catalog carries no callable', () => {
for (const action of registries.allEventActions()) {
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
+ assert.equal(action.reconcile, undefined, `${action.id} leaked reconcile`)
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
}
// …and the runner's own lookup still has it, which is the half that makes the
@@ -242,7 +248,7 @@ test('a whole batch is refused or taken, never half', () => {
test('_reset() hands the process back', () => {
registries.registerCore()
- assert.equal(registries.allEventActions().length, 3)
+ assert.equal(registries.allEventActions().length, 4)
registries._reset()
assert.equal(registries.allEventActions().length, 0)
assert.equal(registries.isEventAction('core.wait'), false)
@@ -366,9 +372,80 @@ test('an option source needs a resolver, and core registers one of its own', ()
// Core through the same door (Phase 7): `core.announce`'s `leg` param names a
// source, and the announce legs are already a registry with labels in them.
registries.registerCore()
- assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['core.options.legs'])
+ assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), [
+ 'core.options.legs',
+ // Phase 8's, and it is the same argument one phase on: `core.lease`'s `lease`
+ // param would otherwise be a free-text box whose typo is caught at dispatch,
+ // mid-run — and the leases are already a registry with labels in them.
+ 'core.options.leases',
+ ])
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
assert.equal(leg.source, 'core.options.legs')
+ const which = registries.eventAction('core.lease').params.find((p) => p.name === 'lease')
+ assert.equal(which.source, 'core.options.leases')
+})
+
+// ── `reconcile`, the one member Phase 8 added to the action shape ──────────
+//
+// Optional where `revert` is required, and the asymmetry is the design: a module
+// that cannot say what the game still has is not broken — core keeps believing
+// its own ledger, which is the behaviour before this phase — whereas a module
+// that created something and cannot undo it has made a promise core has no way
+// to keep.
+
+test('reconcile is optional, must be a function, and only on an action that ledgers', () => {
+ const ledgering = {
+ id: 'demo.spawn',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ perform: async () => ({ ok: true }),
+ revert: async () => ({ ok: true }),
+ }
+
+ // Absent is legal, and it lands as an explicit null rather than as a missing
+ // key — the same shape `revert` and `cost` take, so the catalog's strip list
+ // and the sweep's `typeof` check both have something to look at.
+ register('demo', [ledgering])
+ assert.equal(registries.eventAction('demo.spawn').reconcile, null)
+ registries._reset()
+
+ assert.throws(
+ () => register('demo', [{ ...ledgering, reconcile: 'yes please' }]),
+ /reconcile must be a function/,
+ )
+
+ // The mirror check `revert` already has. An action that ledgers nothing has no
+ // rows for core to ask about, so a `reconcile` on one is an author who believes
+ // something is being tracked and a sweep that will never call it.
+ assert.throws(
+ () =>
+ register('demo', [
+ {
+ id: 'demo.shout',
+ label: 'Shout',
+ risk: 'notify',
+ reversible: 'none',
+ perform: async () => ({ ok: true }),
+ reconcile: async () => ({ ok: true, inForce: [] }),
+ },
+ ]),
+ /declares reconcile\(\) but is reversible: 'none' and ledgers nothing/,
+ )
+})
+
+test('an override action may reconcile, because a lease is ledgered too', () => {
+ register('demo', [
+ {
+ id: 'demo.borrow',
+ label: 'Borrow',
+ risk: 'change',
+ reversible: 'override',
+ perform: async () => ({ ok: true }),
+ reconcile: async () => ({ ok: true, inForce: [] }),
+ },
+ ])
+ assert.equal(typeof registries.eventAction('demo.borrow').reconcile, 'function')
})
test('_reset() hands back the three new registries too', () => {
diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js
new file mode 100644
index 0000000..f70bc56
--- /dev/null
+++ b/server/test/eventCleanup.test.js
@@ -0,0 +1,500 @@
+// ── Giving back what a run took (EVENTS_PLAN.md Phase 8) ───────────────────
+//
+// The phase's shipped claim: **cleanup is generated from the ledger and runs on
+// every terminal path.** An operator cannot be relied on to write the undo, and
+// an aborted run never reaches the phase they wrote it in — so there is no
+// cleanup phase in a spec, no `on_teardown` on an action, and one function that
+// reads rows.
+//
+// The properties around it are §L's, and two of them are ones this codebase has
+// already paid for once:
+//
+// • **a revert that never succeeds stays visible rather than cycling** — rule 2,
+// and `MAX_REVERT_ATTEMPTS` is what stops the automatic retry. Only a human
+// clears the counter, which is Engagement Phase 14's rule stated a third time
+// • **reverting something that does not exist is a SUCCESS** — §L, and what a
+// Rust wipe needs
+// • **drift is not an error.** The module did exactly what it was asked and
+// found somebody else's value in place. A restore that wrote anyway would
+// silently revert an operator's manual fix
+// • **a resource the module no longer has becomes `orphaned`, never `reverted`** —
+// reverting it would be core recording that it put something back when what
+// happened is that the thing vanished
+// • **"I do not know" is never read as "it is gone".** Every unanswerable
+// reconcile leaves the ledger alone
+//
+// The sweep is driven against a stubbed db layer, exactly as `eventRunner.test.js`
+// drives the runner: what a stub cannot prove is the SQL, and the unique key that
+// makes two events unable to lease one target runs against a real MariaDB in
+// `eventRunnerSql.test.js`.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const cleanup = require('../src/events/cleanup')
+const resourcesDb = require('../src/model/events/eventRunResources.db')
+const runsDb = require('../src/model/events/eventRuns.db')
+const stepsDb = require('../src/model/events/eventRunSteps.db')
+const logDb = require('../src/model/events/eventRunLog.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const UNRESOLVED = ['pending', 'confirmed', 'reverting', 'orphaned', 'drifted']
+
+let store
+const originals = {
+ resourcesDb: { ...resourcesDb },
+ runsDb: { ...runsDb },
+ stepsDb: { ...stepsDb },
+ logDb: { ...logDb },
+}
+
+beforeEach(() => {
+ registries._reset()
+ store = { rows: new Map(), steps: new Map(), log: [], next: 1, cleanupStatus: 'pending' }
+
+ resourcesDb.unresolvedForRun = async (runId, { maxAttempts = null } = {}) =>
+ [...store.rows.values()]
+ .filter((r) => r.run_id === runId && UNRESOLVED.includes(r.status))
+ .filter((r) => maxAttempts === null || r.revert_attempts < maxAttempts)
+ .map((r) => ({ ...r }))
+ resourcesDb.unresolvedCount = async (runId) =>
+ [...store.rows.values()].filter((r) => r.run_id === runId && UNRESOLVED.includes(r.status)).length
+ resourcesDb.claimRevert = async (id) => {
+ const r = store.rows.get(id)
+ if (!r || !['pending', 'confirmed', 'orphaned', 'drifted'].includes(r.status)) return false
+ r.status = 'reverting'
+ return true
+ }
+ resourcesDb.markReverted = async (id) => {
+ const r = store.rows.get(id)
+ if (r) Object.assign(r, { status: 'reverted', last_error: null })
+ }
+ resourcesDb.failRevert = async (id, error, restoreTo = 'confirmed') => {
+ const r = store.rows.get(id)
+ if (r) Object.assign(r, { status: restoreTo, revert_attempts: r.revert_attempts + 1, last_error: String(error) })
+ }
+ resourcesDb.resetAttempts = async (runId) => {
+ let n = 0
+ for (const r of store.rows.values()) {
+ if (r.run_id === runId && UNRESOLVED.includes(r.status)) {
+ r.revert_attempts = 0
+ n += 1
+ }
+ }
+ return n
+ }
+ resourcesDb.markOrphaned = async (id, detail = null) => {
+ const r = store.rows.get(id)
+ if (r && ['pending', 'confirmed', 'reverting'].includes(r.status)) {
+ Object.assign(r, { status: 'orphaned', last_error: detail })
+ }
+ }
+ resourcesDb.liveForModule = async (owner) =>
+ [...store.rows.values()].filter((r) => r.owner_module === owner && ['pending', 'confirmed'].includes(r.status)).map((r) => ({ ...r }))
+ resourcesDb.modulesWithLiveRows = async () => [
+ ...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)),
+ ]
+ resourcesDb.runsNeedingCleanup = async () => store.candidates || []
+
+ runsDb.setCleanupStatus = async (id, to, from = null) => {
+ if (from && !from.includes(store.cleanupStatus)) return false
+ store.cleanupStatus = to
+ return true
+ }
+ stepsDb.getById = async (id) => store.steps.get(id) || null
+ logDb.write = async (entry) => {
+ store.log.push(entry)
+ }
+})
+
+afterEach(() => {
+ Object.assign(resourcesDb, originals.resourcesDb)
+ Object.assign(runsDb, originals.runsDb)
+ Object.assign(stepsDb, originals.stepsDb)
+ Object.assign(logDb, originals.logDb)
+ registries._reset()
+})
+
+const RUN = { id: 7, status: 'completed', cleanup_status: 'pending' }
+
+function addStep(id, actionId, key = 'k'.repeat(40)) {
+ store.steps.set(id, { id, run_id: RUN.id, action_id: actionId, idempotency_key: key, phase: 'p', seq: 0 })
+}
+
+function addResource(over = {}) {
+ const id = store.next++
+ const row = {
+ id,
+ run_id: RUN.id,
+ step_id: 1,
+ owner_module: 'demo',
+ kind: 'creature',
+ ref: `0x${id}`,
+ payload: null,
+ lease_until: null,
+ status: 'confirmed',
+ revert_attempts: 0,
+ last_error: null,
+ member_key: null,
+ ...over,
+ }
+ store.rows.set(id, row)
+ return row
+}
+
+function registerAction(over = {}) {
+ const api = registries.stage('demo')
+ api.registerEventActions([
+ {
+ id: 'demo.spawn',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ budgetMs: 500,
+ perform: async () => ({ ok: true }),
+ revert: async () => ({ ok: true }),
+ ...over,
+ },
+ ])
+ registries.apply(api.staged)
+}
+
+function registerLease(over = {}) {
+ const api = registries.stage('demo')
+ api.registerEventLeases([
+ {
+ id: 'demo.rate',
+ label: 'Gather rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3_600_000,
+ read: async () => ({ ok: true, value: 1 }),
+ apply: async () => ({ ok: true }),
+ restore: async () => ({ ok: true }),
+ ...over,
+ },
+ ])
+ registries.apply(api.staged)
+}
+
+const kinds = () => store.log.map((e) => e.kind)
+
+// ── The shipped claim ──────────────────────────────────────────────────────
+
+test('a run\'s ledger is given back in one call per step, and the run goes complete', async () => {
+ // `revert` takes a LIST because that is what makes twelve creatures one round
+ // trip rather than twelve. The grouping key is the STEP, because the resource
+ // row records the module and the opaque names while the step records the verb.
+ let seen = null
+ registerAction({ revert: async (arg) => { seen = arg; return { ok: true } } })
+ addStep(1, 'demo.spawn', 'key-one')
+ addResource({ ref: '0xA' })
+ addResource({ ref: '0xB' })
+
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.deepEqual(summary, { attempted: 2, reverted: 2, drifted: 0, failed: 0, remaining: 0 })
+ assert.equal(seen.runId, RUN.id)
+ assert.equal(seen.idempotencyKey, 'key-one')
+ assert.deepEqual(seen.resources.map((r) => r.ref), ['0xA', '0xB'])
+ assert.equal(store.cleanupStatus, 'complete')
+})
+
+test('two steps are two calls, and one failing does not take the other down', async () => {
+ const calls = []
+ registerAction({
+ revert: async ({ idempotencyKey, resources }) => {
+ calls.push(idempotencyKey)
+ return idempotencyKey === 'bad' ? { ok: false, error: 'the shard did not answer' } : { ok: true, resources }
+ },
+ })
+ addStep(1, 'demo.spawn', 'good')
+ addStep(2, 'demo.spawn', 'bad')
+ addResource({ step_id: 1 })
+ addResource({ step_id: 2 })
+
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(calls.length, 2)
+ assert.equal(summary.reverted, 1)
+ assert.equal(summary.failed, 1)
+ assert.equal(summary.remaining, 1)
+ // **Still `pending`, because the failed row has retries left.** `incomplete`
+ // means "finished with, and not finished" — it is what takes a run out of the
+ // sweep's own scan, so writing it after the FIRST failure made
+ // `MAX_REVERT_ATTEMPTS` quietly mean one attempt. Found by watching
+ // `revert_attempts` sit at 1 through half a minute of live ticks.
+ assert.equal(store.cleanupStatus, 'pending')
+})
+
+test('reverting something that does not exist is a success', async () => {
+ // §L, and the Rust wipe: "gone, and that is fine". The module never has to
+ // distinguish "I deleted it" from "it was not there" — which is also what makes
+ // a placeholder for an object that may never have existed safe to write.
+ registerAction({ revert: async () => ({ ok: true, detail: 'resource no longer exists' }) })
+ addStep(1, 'demo.spawn')
+ addResource()
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.reverted, 1)
+ assert.equal(store.cleanupStatus, 'complete')
+})
+
+test('a module may name the ones that did not come back', async () => {
+ // Partial cleanup is the ordinary case — eleven of twelve creatures deleted —
+ // and it is why the ledger is a row per object rather than a row per step.
+ registerAction({ revert: async () => ({ ok: true, failed: ['0x2'] }) })
+ addStep(1, 'demo.spawn')
+ addResource({ ref: '0x1' })
+ addResource({ ref: '0x2' })
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.reverted, 1)
+ assert.equal(summary.failed, 1)
+ assert.match([...store.rows.values()].find((r) => r.ref === '0x2').last_error, /could not give "0x2" back/)
+})
+
+// ── Rule 2: loud and sticky ────────────────────────────────────────────────
+
+test('a revert that never works stops retrying and stays visible', async () => {
+ let calls = 0
+ registerAction({ revert: async () => { calls += 1; return { ok: false, error: 'nope' } } })
+ addStep(1, 'demo.spawn')
+ addResource()
+
+ for (let i = 0; i < 6; i++) await cleanup.cleanupRun(RUN)
+
+ // Bounded at MAX_REVERT_ATTEMPTS, exactly like a step's attempts. A fourth ask
+ // of a shard that has answered the same way three times is not new information,
+ // and an unbounded counter is a row nothing can ever sweep.
+ assert.equal(calls, cleanup.MAX_REVERT_ATTEMPTS)
+ const row = [...store.rows.values()][0]
+ assert.equal(row.revert_attempts, cleanup.MAX_REVERT_ATTEMPTS)
+ assert.equal(row.status, 'confirmed')
+ assert.equal(row.last_error, 'nope')
+ // And ONLY now, with nothing left to try, does the run stop being the sweep's
+ // business. §L: it does not stay `running` — an event whose world changes are
+ // still up is a real state, and pretending the event is in progress hides it.
+ assert.equal(store.cleanupStatus, 'incomplete')
+})
+
+test('the run goes back to pending each time it still has an attempt left', async () => {
+ // The other half of the same rule, watched one pass at a time rather than at
+ // the end. Each of the first two sweeps leaves the run in the scan; the third
+ // takes it out. A test that only looked at the end state would pass against the
+ // defect this replaced.
+ registerAction({ revert: async () => ({ ok: false, error: 'nope' }) })
+ addStep(1, 'demo.spawn')
+ addResource()
+
+ const seen = []
+ for (let i = 0; i < 3; i++) {
+ await cleanup.cleanupRun(RUN)
+ seen.push(store.cleanupStatus)
+ }
+ assert.deepEqual(seen, ['pending', 'pending', 'incomplete'])
+})
+
+test('only a human clears the attempt counter', async () => {
+ // Engagement Phase 14's defect, stated a third time: a SWEEP that returned every
+ // stale row to its start state made the attempt ceiling unreachable, so the row
+ // cycled for ever and was never eligible for any retention sweep. The automatic
+ // leg must never do this; the cleanup route may, because a person asked.
+ let calls = 0
+ registerAction({ revert: async () => { calls += 1; return { ok: false, error: 'nope' } } })
+ addStep(1, 'demo.spawn')
+ addResource()
+
+ for (let i = 0; i < 5; i++) await cleanup.cleanupRun(RUN)
+ assert.equal(calls, cleanup.MAX_REVERT_ATTEMPTS)
+
+ await cleanup.cleanupRun(RUN, { resetAttempts: true, actor: 9 })
+ assert.equal(calls, cleanup.MAX_REVERT_ATTEMPTS + 1)
+ assert.ok(store.log.some((e) => e.kind === 'cleanup.retry' && e.detail.by === 9))
+})
+
+test('a module that throws from revert is a transient failure, not a crashed sweep', async () => {
+ registerAction({ revert: async () => { throw new Error('socket hung up') } })
+ addStep(1, 'demo.spawn')
+ addResource()
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.failed, 1)
+ assert.match([...store.rows.values()][0].last_error, /socket hung up/)
+})
+
+test('no shape a revert failure can take reads as success', () => {
+ // `dispatch.classify`'s rule, applied to the other direction of the contract.
+ // The expensive mistake here is the mirror of the one there: recording that a
+ // world change was UNDONE when it was not.
+ for (const raw of [null, undefined, 'ok', [], {}, { ok: 'yes' }, { ok: 1 }]) {
+ assert.notEqual(cleanup.classifyRevert(raw, 'demo.spawn').outcome, 'done', JSON.stringify(raw))
+ }
+ assert.equal(cleanup.classifyRevert({ __timedOut: true, error: 'slow' }, 'x').outcome, 'retry')
+ assert.equal(cleanup.classifyRevert({ ok: false, retry: false, error: 'never' }, 'x').outcome, 'terminal')
+ assert.equal(cleanup.classifyRevert({ ok: true }, 'x').outcome, 'done')
+})
+
+test('an action whose module is gone leaves its rows unresolved with the reason', async () => {
+ // Not a retry — nothing will change until an operator reinstalls it — and not
+ // an orphan either, because core has no idea whether the thing is still there.
+ addStep(1, 'demo.spawn')
+ addResource()
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.failed, 1)
+ assert.match([...store.rows.values()][0].last_error, /no module registers "demo.spawn"/)
+ // Retried like any other failure rather than given up on at once, and that is
+ // the right uniformity here: "nothing registers this" stops being true the
+ // moment an operator reinstalls the module, and three registry lookups cost
+ // nothing. So it is `pending` until the attempts are spent.
+ assert.equal(store.cleanupStatus, 'pending')
+})
+
+// ── Leases ─────────────────────────────────────────────────────────────────
+
+test('a lease is restored through the LEASE registry, not through any action', async () => {
+ // The split §F draws: core owns the duration and the conflict check, the module
+ // owns reading and writing. It is why `core.lease` needs no `revert()` of its
+ // own, and why an `override` row routes here rather than to its step's action.
+ let seen = null
+ registerLease({ restore: async (baseline, opts) => { seen = { baseline, opts }; return { ok: true } } })
+ addResource({ kind: 'override', ref: 'demo.rate', step_id: null, payload: { baseline: 1, applied: 3 } })
+
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.reverted, 1)
+ assert.equal(seen.baseline, 1)
+ // The drift check's input. `restore` MUST verify current === expected before
+ // writing, and a lease whose restore wrote blindly would silently revert an
+ // operator's manual fix.
+ assert.equal(seen.opts.expected, 3)
+})
+
+test('drift is not an error: the world is left alone and the row says so', async () => {
+ registerLease({ restore: async () => ({ ok: false, drifted: true, current: 4.5 }) })
+ addResource({ kind: 'override', ref: 'demo.rate', step_id: null, payload: { baseline: 1, applied: 3 } })
+
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.drifted, 1)
+ assert.equal(summary.failed, 0)
+ const row = [...store.rows.values()][0]
+ assert.equal(row.status, 'drifted')
+ assert.match(row.last_error, /now 4\.5 rather than what this run applied/)
+ // Still surfaced. §L: "surfaced beside the unreverted ones" — the run does not
+ // get to call itself clean because somebody else took the value. It is `pending`
+ // rather than `incomplete` for one more reason worth keeping: drift is retried
+ // like any other failure, because a GM who puts the value back between two ticks
+ // should have the lease close cleanly.
+ assert.equal(store.cleanupStatus, 'pending')
+})
+
+test('a lease whose module is uninstalled is unresolved, never assumed restored', async () => {
+ addResource({ kind: 'override', ref: 'demo.rate', step_id: null, payload: { baseline: 1, applied: 3 } })
+ const summary = await cleanup.cleanupRun(RUN)
+ assert.equal(summary.failed, 1)
+ assert.match([...store.rows.values()][0].last_error, /no module registers the lease "demo.rate"/)
+})
+
+// ── The sweep ──────────────────────────────────────────────────────────────
+
+test('the sweep only touches TERMINAL runs', async () => {
+ // A run still in flight has a ledger that is still growing, and reverting a
+ // resource the next step is about to use would be core undoing an event while
+ // it is happening.
+ registerAction()
+ addStep(1, 'demo.spawn')
+ addResource()
+ store.candidates = [{ id: RUN.id, status: 'running', cleanup_status: 'pending' }]
+ assert.equal(await cleanup.sweep(), 0)
+ assert.equal([...store.rows.values()][0].status, 'confirmed')
+
+ store.candidates = [{ id: RUN.id, status: 'cancelled', cleanup_status: 'pending' }]
+ assert.equal(await cleanup.sweep(), 1)
+ assert.equal([...store.rows.values()][0].status, 'reverted')
+})
+
+// ── Reconcile ──────────────────────────────────────────────────────────────
+
+test('a resource the module no longer has becomes orphaned, never reverted', async () => {
+ // §L, and the distinction matters to the operator reading the console
+ // afterwards: `reverted` says core put something back, `orphaned` says the
+ // thing vanished while nobody was looking. Recording the second as the first
+ // would be core claiming credit for a shard restart.
+ registerAction({ reconcile: async () => ({ ok: true, inForce: ['0x1'] }) })
+ addStep(1, 'demo.spawn')
+ addResource({ ref: '0x1' })
+ addResource({ ref: '0x2' })
+
+ const summary = await cleanup.reconcileModule('demo')
+ assert.deepEqual(summary, { asked: 2, inForce: 1, orphaned: 1, unanswered: 0 })
+ assert.equal([...store.rows.values()].find((r) => r.ref === '0x2').status, 'orphaned')
+ assert.equal([...store.rows.values()].find((r) => r.ref === '0x1').status, 'confirmed')
+ assert.ok(store.log.some((e) => e.kind === 'resource.orphaned'))
+})
+
+test('"I do not know" is never read as "it is gone"', async () => {
+ // Every unanswerable shape leaves the ledger exactly as it was. A reconcile
+ // that read silence as absence would orphan a whole shard's worth of live
+ // spawns the first time a sidecar was slow.
+ for (const answer of [null, undefined, { ok: false }, { ok: true }, { ok: true, inForce: 'all' }, 'yes']) {
+ store.rows.clear()
+ store.log.length = 0
+ registries._reset()
+ registerAction({ reconcile: async () => answer })
+ addStep(1, 'demo.spawn')
+ addResource({ ref: '0x1' })
+ const summary = await cleanup.reconcileModule('demo')
+ assert.equal(summary.orphaned, 0, JSON.stringify(answer))
+ assert.equal(summary.unanswered, 1, JSON.stringify(answer))
+ assert.equal([...store.rows.values()][0].status, 'confirmed')
+ }
+})
+
+test('a module with no reconcile is not broken; core keeps believing its ledger', async () => {
+ // Optional where `revert` is required. A module that cannot answer leaves core
+ // exactly where it was before this phase, which is a capability its deployment
+ // does without rather than a boot it fails.
+ registerAction()
+ addStep(1, 'demo.spawn')
+ addResource()
+ const summary = await cleanup.reconcileModule('demo')
+ assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 1 })
+ assert.equal([...store.rows.values()][0].status, 'confirmed')
+})
+
+test('a reconcile that throws orphans nothing', async () => {
+ registerAction({ reconcile: async () => { throw new Error('sidecar gone') } })
+ addStep(1, 'demo.spawn')
+ addResource()
+ const summary = await cleanup.reconcileModule('demo')
+ assert.equal(summary.unanswered, 1)
+ assert.equal([...store.rows.values()][0].status, 'confirmed')
+})
+
+test('placeholders are not asked about, because there is nothing to ask yet', async () => {
+ // A `@step` row names no object — it says "a dispatch was in flight and may
+ // have made something". Asking a module whether it is in force is a question
+ // with no answer, and reading a shrug as absence would resolve the one row whose
+ // survival is the safety property.
+ registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
+ addStep(1, 'demo.spawn')
+ addResource({ kind: resourcesDb.STEP_KIND, ref: 'a'.repeat(40), status: 'pending' })
+ const summary = await cleanup.reconcileModule('demo')
+ assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 })
+ assert.equal([...store.rows.values()][0].status, 'pending')
+})
+
+test('reconcileAll asks every module that owns a live row', async () => {
+ registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
+ addStep(1, 'demo.spawn')
+ addStep(2, 'other.thing')
+ addResource({ owner_module: 'demo' })
+ addResource({ owner_module: 'other', step_id: 2 })
+ const out = await cleanup.reconcileAll()
+ assert.deepEqual(Object.keys(out).sort(), ['demo', 'other'])
+ assert.equal(out.demo.orphaned, 1)
+ // The other module registers nothing, so its row is left alone rather than
+ // orphaned by a module that is not there to be asked.
+ assert.equal(out.other.unanswered, 1)
+})
diff --git a/server/test/eventLedger.test.js b/server/test/eventLedger.test.js
new file mode 100644
index 0000000..2f41ce8
--- /dev/null
+++ b/server/test/eventLedger.test.js
@@ -0,0 +1,337 @@
+// ── The resource ledger's write half (EVENTS_PLAN.md Phase 8) ──────────────
+//
+// §D's two rules, and rule 1 is the one this file exists for: **a resource is
+// recorded BEFORE it is confirmed.** The obstacle it works around is that a
+// spawn's serial does not exist until the module answers, so what goes in before
+// the dispatch is a placeholder keyed by the step's idempotency key — and the
+// property worth a test is that the placeholder SURVIVES an answer that never
+// comes, because that is the case where recording afterwards would have lost the
+// object for ever.
+//
+// The other rules here are about what core will and will not write down on a
+// module's say-so. Every one of them is fail-closed in a specific direction:
+//
+// • the reserved `@step` kind is core's and a module may not claim it
+// • an `override` must name a lease core knows how to give back, or core would
+// be recording something it has no way to restore
+// • a duplicate is "already recorded", not an error — a retry re-sends the same
+// idempotency key and a module may honestly report the same resources twice
+// • a badly shaped resource is dropped and LOGGED, never a failed step: the
+// step changed the world, and turning bookkeeping into a retry would re-run
+// a world write that already happened
+//
+// The db layer is stubbed with a store that enforces `uq_evres_target`, because
+// that refusal is behaviour the callers branch on rather than an implementation
+// detail. The SQL itself is `eventRunnerSql.test.js`'s, against a real MariaDB.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const ledger = require('../src/events/ledger')
+const resourcesDb = require('../src/model/events/eventRunResources.db')
+const runsDb = require('../src/model/events/eventRuns.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const HELD = ['pending', 'confirmed', 'reverting']
+
+let store
+const originals = { resourcesDb: { ...resourcesDb }, runsDb: { ...runsDb } }
+
+beforeEach(() => {
+ registries._reset()
+ store = { rows: new Map(), next: 1, cleanupStatus: 'not_required' }
+
+ resourcesDb.reserve = async ({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) => {
+ const holder = [...store.rows.values()].find(
+ (r) => r.owner_module === owner && r.kind === kind && r.ref === ref && HELD.includes(r.status),
+ )
+ if (holder) return { ok: false, code: 'held', holder: { run_id: holder.run_id, status: holder.status } }
+ const id = store.next++
+ store.rows.set(id, {
+ id,
+ run_id: runId,
+ step_id: stepId,
+ owner_module: owner,
+ kind,
+ ref,
+ payload,
+ lease_until: leaseUntil,
+ status: 'pending',
+ revert_attempts: 0,
+ last_error: null,
+ member_key: memberKey,
+ })
+ return { ok: true, id }
+ }
+ resourcesDb.confirm = async (id) => {
+ const r = store.rows.get(id)
+ if (!r || r.status !== 'pending') return false
+ r.status = 'confirmed'
+ return true
+ }
+ resourcesDb.resolvePlaceholder = async (id) => {
+ const r = store.rows.get(id)
+ if (!r || r.kind !== resourcesDb.STEP_KIND) return false
+ r.status = 'reverted'
+ return true
+ }
+ resourcesDb.findByTarget = async (owner, kind, ref) =>
+ [...store.rows.values()].reverse().find((r) => r.owner_module === owner && r.kind === kind && r.ref === ref) || null
+
+ runsDb.setCleanupStatus = async (id, to, from = null) => {
+ if (from && !from.includes(store.cleanupStatus)) return false
+ store.cleanupStatus = to
+ return true
+ }
+})
+
+afterEach(() => {
+ Object.assign(resourcesDb, originals.resourcesDb)
+ Object.assign(runsDb, originals.runsDb)
+ registries._reset()
+})
+
+const RUN = { id: 7 }
+const STEP = { id: 42, phase: 'invasion', seq: 0, idempotency_key: 'a'.repeat(40) }
+
+const action = (over = {}) => ({
+ id: 'demo.spawn',
+ owner: 'demo',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ budgetMs: 1000,
+ ...over,
+})
+
+const rows = () => [...store.rows.values()]
+
+// ── Which actions ledger at all ────────────────────────────────────────────
+
+test('only the two reversible classes core has to come back for are ledgered', () => {
+ // `none` is gone once done, `self` undoes itself. Neither has anything core
+ // could revert, and giving one a placeholder would put a row in the ledger that
+ // teardown could never resolve — the exact reason `core.announce` is declared
+ // `none` rather than `ledger`.
+ assert.equal(ledger.ledgers(action({ reversible: 'ledger' })), true)
+ assert.equal(ledger.ledgers(action({ reversible: 'override' })), true)
+ assert.equal(ledger.ledgers(action({ reversible: 'none' })), false)
+ assert.equal(ledger.ledgers(action({ reversible: 'self' })), false)
+})
+
+test('only a ledger action gets a placeholder; an override reserves its own target', async () => {
+ // The asymmetry is the design. A spawn's ref is unknown until the module
+ // answers, so the placeholder stands in for it; a lease's target is the lease
+ // id the step already names, so `core.lease` writes the real row before it
+ // touches the world — which is rule 1 in a stronger form, and the only place
+ // the two-events-one-target refusal can happen before the world has changed.
+ assert.equal(typeof (await ledger.reserveStep(RUN, STEP, action())), 'number')
+ assert.equal(await ledger.reserveStep(RUN, STEP, action({ id: 'demo.b', reversible: 'override' })), null)
+ assert.equal(await ledger.reserveStep(RUN, STEP, action({ id: 'demo.c', reversible: 'none' })), null)
+ assert.equal(rows().length, 1)
+ assert.equal(rows()[0].kind, '@step')
+ assert.equal(rows()[0].ref, STEP.idempotency_key)
+})
+
+test('the first ledger row is what makes a run dirty, and only from not_required', async () => {
+ assert.equal(store.cleanupStatus, 'not_required')
+ await ledger.reserveStep(RUN, STEP, action())
+ assert.equal(store.cleanupStatus, 'pending')
+
+ // A run whose sweep has already finished must not be walked back to `pending`
+ // by a late row: only a human's cleanup re-opens it, and it does so
+ // deliberately and with an actor on the log line.
+ store.cleanupStatus = 'complete'
+ await ledger.recordAnswer({
+ run: RUN,
+ step: { ...STEP, id: 43, idempotency_key: 'b'.repeat(40) },
+ action: action(),
+ placeholderId: null,
+ resources: [{ kind: 'creature', ref: '0x1' }],
+ })
+ assert.equal(store.cleanupStatus, 'complete')
+})
+
+// ── Rule 1 ─────────────────────────────────────────────────────────────────
+
+test('a lost acknowledgement leaves the placeholder standing, which is the whole point', async () => {
+ const placeholderId = await ledger.reserveStep(RUN, STEP, action())
+ // The dispatch timed out: no answer, so `recordAnswer` is never reached. This
+ // is the case rule 1 exists for — record afterwards and the object the module
+ // may well have created is invisible to cleanup for ever.
+ assert.equal(rows()[0].status, 'pending')
+ assert.equal(rows()[0].payload.action, 'demo.spawn')
+ assert.ok(placeholderId)
+})
+
+test('a retry reuses its own placeholder rather than writing a second', async () => {
+ // An idempotency key is minted once per step and does not vary by attempt (§E),
+ // so the second attempt's insert collides with the first attempt's row. Finding
+ // it already there is the correct answer, and a second row would be a second
+ // thing for cleanup to revert.
+ const first = await ledger.reserveStep(RUN, STEP, action())
+ const second = await ledger.reserveStep(RUN, STEP, action())
+ assert.equal(first, second)
+ assert.equal(rows().length, 1)
+})
+
+test('the placeholder is resolved once the real rows exist', async () => {
+ const placeholderId = await ledger.reserveStep(RUN, STEP, action())
+ const out = await ledger.recordAnswer({
+ run: RUN,
+ step: STEP,
+ action: action(),
+ placeholderId,
+ resources: [
+ { kind: 'creature', ref: '0x40001234' },
+ { kind: 'creature', ref: '0x40001235' },
+ ],
+ })
+ assert.equal(out.recorded, 2)
+ assert.deepEqual(out.rejected, [])
+ assert.equal(store.rows.get(placeholderId).status, 'reverted')
+ assert.deepEqual(
+ rows().filter((r) => r.kind === 'creature').map((r) => [r.ref, r.status]),
+ [['0x40001234', 'confirmed'], ['0x40001235', 'confirmed']],
+ )
+})
+
+test('an action that ledgers and reports nothing still resolves its placeholder', async () => {
+ // "I made nothing" is a real answer. Holding the placeholder open for it would
+ // make cleanup call `revert()` on every terminal path, for ever, for a step that
+ // has nothing to give back.
+ const placeholderId = await ledger.reserveStep(RUN, STEP, action())
+ const out = await ledger.recordAnswer({ run: RUN, step: STEP, action: action(), placeholderId, resources: [] })
+ assert.equal(out.recorded, 0)
+ assert.equal(store.rows.get(placeholderId).status, 'reverted')
+})
+
+test('a module reporting the same resources twice produces one row', async () => {
+ // The database is what makes recording idempotent: `uq_evres_target` refuses
+ // the second insert and this file reads that as "already recorded". Without it
+ // a retry against a module that honestly re-reports its work would double every
+ // row cleanup then has to revert.
+ const args = { run: RUN, step: STEP, action: action(), placeholderId: null, resources: [{ kind: 'creature', ref: '0x1' }] }
+ await ledger.recordAnswer(args)
+ const again = await ledger.recordAnswer(args)
+ assert.equal(again.recorded, 0)
+ assert.deepEqual(again.rejected, [])
+ assert.equal(rows().filter((r) => r.kind === 'creature').length, 1)
+})
+
+test('a target another RUN holds is rejected by name rather than silently skipped', async () => {
+ await ledger.recordAnswer({
+ run: { id: 1 },
+ step: STEP,
+ action: action(),
+ placeholderId: null,
+ resources: [{ kind: 'creature', ref: '0x1' }],
+ })
+ const out = await ledger.recordAnswer({
+ run: { id: 2 },
+ step: { ...STEP, id: 99 },
+ action: action(),
+ placeholderId: null,
+ resources: [{ kind: 'creature', ref: '0x1' }],
+ })
+ assert.equal(out.recorded, 0)
+ assert.match(out.rejected.join('\n'), /already held by run 1/)
+})
+
+// ── What core will not write down ──────────────────────────────────────────
+
+test('a module may not claim core\'s reserved kind', () => {
+ // A module that could write a `@step` row could make its own step's placeholder
+ // look resolved — which is the one row whose survival is the safety property.
+ const bad = ledger.normalise({ kind: '@step', ref: 'x' }, 'demo.spawn')
+ assert.equal(bad.ok, false)
+ assert.match(bad.reason, /reserved kind/)
+})
+
+test('an override must name a lease core knows how to give back', () => {
+ // Core restores an `override` through the LEASE registry — that is the split §F
+ // draws — so a ref naming nothing registered is a resource core would be
+ // recording with no way to undo it. Refusing to record it is the fail-closed
+ // direction: rule 2 is a promise core must not make and then break.
+ assert.equal(ledger.normalise({ kind: 'override', ref: 'demo.rate' }, 'demo.x').ok, false)
+
+ const api = registries.stage('demo')
+ api.registerEventLeases([
+ {
+ id: 'demo.rate',
+ label: 'Rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3_600_000,
+ read: async () => ({ ok: true, value: 1 }),
+ apply: async () => ({ ok: true }),
+ restore: async () => ({ ok: true }),
+ },
+ ])
+ registries.apply(api.staged)
+ assert.equal(ledger.normalise({ kind: 'override', ref: 'demo.rate' }, 'demo.x').ok, true)
+})
+
+test('every bad shape is refused, and none of them is a retry', () => {
+ // A badly shaped resource is the module's mistake rather than the world's, and
+ // it will be just as badly shaped on the second attempt. They are dropped and
+ // reported; the STEP still counts as done, because it is — something happened
+ // in the world, and refusing to record it would be the one outcome worse than
+ // recording it imperfectly.
+ const bad = [
+ null,
+ 'a string',
+ ['an array'],
+ { ref: 'x' }, // no kind
+ { kind: 'creature' }, // no ref
+ { kind: 'creature', ref: 'x'.repeat(200) },
+ { kind: 'k'.repeat(80), ref: 'x' },
+ { kind: 'creature', ref: 'x', memberKey: 'm'.repeat(200) },
+ { kind: 'creature', ref: 'x', until: 'not a date' },
+ ]
+ for (const entry of bad) {
+ assert.equal(ledger.normalise(entry, 'demo.spawn').ok, false, JSON.stringify(entry))
+ }
+})
+
+test('a bad resource never fails the step it came from', async () => {
+ const placeholderId = await ledger.reserveStep(RUN, STEP, action())
+ const out = await ledger.recordAnswer({
+ run: RUN,
+ step: STEP,
+ action: action(),
+ placeholderId,
+ resources: [{ kind: 'creature', ref: '0x1' }, { nonsense: true }],
+ })
+ assert.equal(out.recorded, 1)
+ assert.equal(out.rejected.length, 1)
+ // And the placeholder is still resolved: the good row exists, and leaving the
+ // placeholder open would ask the module to undo the step a second time.
+ assert.equal(store.rows.get(placeholderId).status, 'reverted')
+})
+
+test('a lease deadline and a member key ride through verbatim', async () => {
+ const until = new Date('2026-09-04T00:00:00Z')
+ await ledger.recordAnswer({
+ run: RUN,
+ step: STEP,
+ action: action(),
+ placeholderId: null,
+ resources: [{ kind: 'reward', ref: 'item-1', memberKey: 'Darrow', until, payload: { cliloc: 1234 } }],
+ })
+ const row = rows()[0]
+ assert.equal(row.member_key, 'Darrow')
+ assert.equal(row.lease_until.getTime(), until.getTime())
+ assert.deepEqual(row.payload, { cliloc: 1234 })
+ // Opaque: core stores what the module said and never interprets it, which is
+ // `ctx.teams.activity.push`'s exact treatment one registry along.
+ assert.equal(row.kind, 'reward')
+ assert.equal(row.owner_module, 'demo')
+})
diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js
index 1823400..c9d6520 100644
--- a/server/test/eventModuleContract.test.js
+++ b/server/test/eventModuleContract.test.js
@@ -513,3 +513,242 @@ test('an action whose module is gone goes dormant, and a step naming it fails te
assert.equal(result.dormant, true)
assert.match(result.error, /no module registers "demo\.summon"/)
})
+
+// ── Phase 8: the ledger's two callables, from a module ─────────────────────
+//
+// `revert` was already required at registration for `reversible: 'ledger'` —
+// Phase 1 put that check in. What Phase 8 added is a caller for it, and
+// `reconcile` beside it. Both are proved here through the REAL loader for the
+// same reason the four registrations are: a `revert` a test called directly is a
+// `revert` core might still have no way to reach.
+
+test('a module\'s revert is reached by the cleanup sweep, resources and key in hand', async () => {
+ const record = loadModule('demo', `
+ let seen = null
+ module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'demo.spawn',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ params: [],
+ async perform() { return { ok: true, resources: [{ kind: 'creature', ref: '0xA' }] } },
+ async revert(arg) { seen = arg; return { ok: true } },
+ async reconcile() { return { ok: true, inForce: [] } },
+ }])
+ api.registerEventOptionSources([
+ { id: 'demo.options.seen', label: 'seen', async resolve() { return [{ value: JSON.stringify(seen), label: 'seen' }] } },
+ ])
+ }
+ `)
+ assertRegistered(record)
+
+ const action = registries.eventAction('demo.spawn')
+ // Both callables survived the registration copy — which is not a given: that
+ // copy is explicit rather than a spread, precisely so nothing rides along, and
+ // a member added to the contract without being added to it is a member that
+ // silently does not exist.
+ assert.equal(typeof action.revert, 'function')
+ assert.equal(typeof action.reconcile, 'function')
+ assert.equal(action.owner, 'demo')
+
+ const answer = await action.revert({
+ runId: 3,
+ resources: [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }],
+ idempotencyKey: 'k-1',
+ })
+ assert.deepEqual(answer, { ok: true })
+
+ // Read back through the module's own option source rather than out of a
+ // closure this file holds: the point is that what core PASSED is what the
+ // module SAW, across the seam.
+ const seen = JSON.parse((await registries.resolveOptionSource('demo.options.seen')).options[0].value)
+ assert.equal(seen.runId, 3)
+ assert.equal(seen.idempotencyKey, 'k-1')
+ assert.deepEqual(seen.resources, [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }])
+})
+
+test('reconcile is optional, and a module without one still registers', () => {
+ // The asymmetry with `revert`, from the loader's side. A module that cannot say
+ // what the game still has is not broken — core keeps believing its own ledger,
+ // which is the behaviour before this phase — whereas one that creates something
+ // and cannot undo it has made a promise core has no way to keep.
+ const withNone = loadModule('quiet', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'quiet.spawn',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ params: [],
+ async perform() { return { ok: true } },
+ async revert() { return { ok: true } },
+ }])
+ }`)
+ assertRegistered(withNone)
+ assert.equal(registries.eventAction('quiet.spawn').reconcile, null)
+
+ const withoutRevert = loadModule('broken', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'broken.spawn',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ params: [],
+ async perform() { return { ok: true } },
+ }])
+ }`)
+ assert.equal(withoutRevert.state, 'startup_failed')
+ assert.match(withoutRevert.reason, /reversible: 'ledger' but has no revert\(\)/)
+})
+
+test('a module cannot claim core\'s reserved resource kind', async () => {
+ // `@step` is the placeholder's kind, and the placeholder is the row whose
+ // survival is the safety property: a module able to write one could make its
+ // own step look already accounted for. Refused at recording, and the STEP still
+ // succeeds — because it did.
+ const record = loadModule('sneaky', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'sneaky.spawn',
+ label: 'Spawn',
+ risk: 'change',
+ reversible: 'ledger',
+ params: [],
+ async perform() { return { ok: true, resources: [{ kind: '@step', ref: 'anything' }] } },
+ async revert() { return { ok: true } },
+ }])
+ }`)
+ assertRegistered(record)
+
+ // eslint-disable-next-line global-require
+ const ledger = require('../src/events/ledger')
+ const result = await dispatch.dispatchStep(step('sneaky.spawn'), { run: RUN })
+ assert.equal(result.outcome, 'done')
+ const parsed = ledger.normalise(result.resources[0], 'sneaky.spawn')
+ assert.equal(parsed.ok, false)
+ assert.match(parsed.reason, /reserved kind/)
+})
+
+test('core registers the lease VERB and a module registers the lease', async () => {
+ // The seam working the way round it is meant to (Phase 8). A module ships the
+ // three callables; the verb an author puts in a step is `core.lease`, so the
+ // duration bound and the two-events-one-target conflict check live in one place
+ // rather than being re-implemented once per module and advisory everywhere.
+ const record = loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventLeases([{
+ id: 'demo.rate.gain',
+ label: 'Gain rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3600000,
+ async read() { return { ok: true, value: 1 } },
+ async apply() { return { ok: true } },
+ async restore() { return { ok: true } },
+ }])
+ }`)
+ assertRegistered(record)
+ registries.registerCore()
+
+ // The module registers no ACTION at all, and its lease is still reachable.
+ assert.equal(registries.eventAction('demo.lease'), null)
+ assert.equal(registries.eventAction('core.lease').reversible, 'override')
+
+ // And the dropdown behind `core.lease`'s first param is answered by what the
+ // module declared — resolved per request, so a module that booted later is
+ // still in the list.
+ const options = await registries.resolveOptionSource('core.options.leases')
+ assert.equal(options.ok, true)
+ assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }])
+})
+
+test('core refuses a lease held longer than the module allows', async () => {
+ // The bound is the MODULE's number and the enforcement is CORE's, which is the
+ // §F split stated as one assertion. `retry: false` because a duration that is
+ // too long will still be too long in sixty seconds: it is an authoring error,
+ // not an outage.
+ const record = loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventLeases([{
+ id: 'demo.rate.gain',
+ label: 'Gain rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3600000,
+ async read() { return { ok: true, value: 1 } },
+ async apply() { return { ok: true } },
+ async restore() { return { ok: true } },
+ }])
+ }`)
+ assertRegistered(record)
+ registries.registerCore()
+
+ const tooLong = await dispatch.dispatchStep(
+ step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 120 }),
+ { run: RUN },
+ )
+ assert.equal(tooLong.outcome, 'terminal')
+ assert.match(tooLong.error, /at most 60 minutes, not 120/)
+
+ // The same for a value outside the declared range. Unlike a cap, a bad lease
+ // value is in force the moment it is applied, which is why min/max are required
+ // on the numeric types rather than advisory.
+ const tooBig = await dispatch.dispatchStep(
+ step('core.lease', { lease: 'demo.rate.gain', value: '9', minutes: 10 }),
+ { run: RUN },
+ )
+ assert.equal(tooBig.outcome, 'terminal')
+ assert.match(tooBig.error, /accepts 0\.5 to 5/)
+
+ // And a lease nobody registers, which is the dormancy rule one registry along.
+ const missing = await dispatch.dispatchStep(
+ step('core.lease', { lease: 'demo.nope', value: '3', minutes: 10 }),
+ { run: RUN },
+ )
+ assert.equal(missing.outcome, 'terminal')
+ assert.match(missing.error, /no module registers the lease "demo\.nope"/)
+})
+
+test('a dry run of core.lease checks everything and takes nothing', async () => {
+ // `verify: true` must change nothing and must answer honestly (§F). A verify
+ // that reserved the target would be a dry run that changed something — and it
+ // would then refuse the real run that followed it, which is the worst of both.
+ let applied = 0
+ const record = loadModule('demo', `
+ let applied = 0
+ module.exports = (ctx, api) => {
+ api.registerEventLeases([{
+ id: 'demo.rate.gain',
+ label: 'Gain rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3600000,
+ async read() { return { ok: true, value: 1 } },
+ async apply() { applied += 1; return { ok: true } },
+ async restore() { return { ok: true } },
+ }])
+ api.registerEventOptionSources([
+ { id: 'demo.options.applied', label: 'applied', async resolve() { return [{ value: String(applied), label: 'n' }] } },
+ ])
+ }
+ `)
+ assertRegistered(record)
+ registries.registerCore()
+ void applied
+
+ const ok = await dispatch.dispatchStep(
+ step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10 }),
+ { run: RUN, verify: true },
+ )
+ assert.equal(ok.outcome, 'done')
+ assert.equal((await registries.resolveOptionSource('demo.options.applied')).options[0].value, '0')
+
+ // A dry run that is still a real check: the bad duration is caught with
+ // `verify: true` as well, which is the whole value of the switchboard's
+ // "find out before you schedule it".
+ const bad = await dispatch.dispatchStep(
+ step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 999 }),
+ { run: RUN, verify: true },
+ )
+ assert.equal(bad.outcome, 'terminal')
+})
diff --git a/server/test/eventRunControls.test.js b/server/test/eventRunControls.test.js
index 1cefb6c..2ae7bb0 100644
--- a/server/test/eventRunControls.test.js
+++ b/server/test/eventRunControls.test.js
@@ -35,6 +35,11 @@ const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
+// Phase 8: cancel now decides what happens to the run's world changes, and
+// `cleanupRun` is the eighth control. Same rule as every phase since the fourth —
+// a new leg under a model needs a stub in every file that stubs that layer.
+const resourcesDb = require('../src/model/events/eventRunResources.db')
+const eventCleanup = require('../src/events/cleanup')
const db = require('../src/utils/db')
after(() => db.close())
@@ -48,12 +53,35 @@ const originals = [
['steps', stepsDb, { ...stepsDb }],
['log', logDb, { ...logDb }],
['gates', gatesDb, { ...gatesDb }],
+ ['resources', resourcesDb, { ...resourcesDb }],
+ ['cleanup', eventCleanup, { ...eventCleanup }],
]
function installStubs() {
- store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), nextStepId: 1, nextGateId: 1 }
+ store = { runs: new Map(), steps: new Map(), log: [], gates: new Map(), sweeps: [], unresolved: {}, nextStepId: 1, nextGateId: 1 }
const snap = (o) => ({ ...o })
+ runsDb.setCleanupStatus = async (id, to, from = null) => {
+ const r = store.runs.get(Number(id))
+ if (!r) return false
+ if (from && !from.includes(r.cleanup_status)) return false
+ r.cleanup_status = to
+ return true
+ }
+
+ // The sweep itself is `eventCleanup.test.js`'s subject. What this file is
+ // about is which control calls it, with what, and whether it is allowed to.
+ eventCleanup.cleanupRun = async (run, opts = {}) => {
+ store.sweeps.push({ runId: run.id, ...opts })
+ return { attempted: 1, reverted: 1, drifted: 0, failed: 0, remaining: 0 }
+ }
+
+ // Cancel asks the LEDGER whether the run owes the world anything, so that
+ // `cleanup: false` on a run with nothing recorded does not stamp `incomplete`
+ // over `not_required`. Unstubbed this is the ten-second dead-port wait, for the
+ // fifth time in this feature.
+ resourcesDb.unresolvedCount = async (runId) => store.unresolved[runId] ?? 0
+
runsDb.getById = async (id) => {
const r = store.runs.get(Number(id))
return r ? snap(r) : null
@@ -184,7 +212,7 @@ function seedGate(runId, phase, { kind = 'on', trigger = 'test.trigger', needed
return g
}
-function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
+function seedRun({ status = 'running', phase = 'main', steps = [], cleanupStatus = 'not_required' } = {}) {
const id = nextRunId++
store.runs.set(id, {
id,
@@ -192,6 +220,7 @@ function seedRun({ status = 'running', phase = 'main', steps = [] } = {}) {
version_id: id,
status,
health: 'ok',
+ cleanup_status: cleanupStatus,
current_phase: phase,
claimed_by: null,
claim_expires_at: null,
@@ -559,3 +588,115 @@ test('an empty reason is stored as NULL rather than as an empty string', async (
await controls.pause(id, { reason: ' ' }, ACTOR)
assert.equal(lastLog().detail.reason, null)
})
+
+// ── cancel decides what happens to the world (Phase 8) ─────────────────────
+
+test('cancel gives back what the run took, by default and without waiting for it', async () => {
+ // The teardown is the runner cleanup leg over TERMINAL runs, not this request.
+ // Two reasons, and both are why the control answers at once: a cancel pressed
+ // at two in the morning must not block on a dozen round trips to the shard
+ // that may BE the reason it is being cancelled, and a process that dies
+ // halfway through a teardown has to resume rather than leave a world half
+ // restored with nothing scheduled to finish it.
+ const id = seedRun({ status: 'running', cleanupStatus: 'pending', steps: [{ status: 'pending' }] })
+ store.unresolved[id] = 3
+
+ const result = await controls.cancel(id, { reason: 'called off' }, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.equal(result.cleanup, true)
+ assert.deepEqual(store.sweeps, [], 'the request must not do the teardown itself')
+ // Still `pending`, which is what the leg looks for. The run is terminal the
+ // moment this returns, so the very next tick picks its ledger up.
+ assert.equal(runRow(id).cleanup_status, 'pending')
+ assert.equal(store.log.at(-1).detail.cleanup, true)
+})
+
+test('cancel WITHOUT cleanup is admin-only, even though the route is wider', async () => {
+ // §L: "cancelling without cleanup is a separate, logged, admin-only action."
+ // The route is `admin` + `moderator`, so the narrower gate cannot live in
+ // middleware — WHICH of the two you have to be depends on what is in the body,
+ // exactly as the authoring role floor does (§K).
+ const id = seedRun({ status: 'running', cleanupStatus: 'pending', steps: [{ status: 'pending' }] })
+ store.unresolved[id] = 3
+
+ const refused = await controls.cancel(id, { cleanup: false }, ACTOR, { isAdmin: false })
+ assert.equal(refused.ok, false)
+ assert.equal(refused.status, 403)
+ assert.equal(runRow(id).status, 'running', 'and the run is not cancelled either')
+
+ // A moderator asking for the ordinary cancel is fine: the safe direction is
+ // the default, so the widest gate keeps the button it exists for.
+ const allowed = await controls.cancel(id, {}, ACTOR, { isAdmin: false })
+ assert.equal(allowed.ok, true)
+ assert.equal(allowed.cleanup, true)
+})
+
+test('cancel without cleanup leaves the world changes up, and says so on the run', async () => {
+ // `incomplete` is the truthful value rather than a tidy one: the changes are
+ // still up, they are listed on the console, and the log line records who
+ // decided that. A `complete` here would be the "tidy completed row over a shard
+ // full of orphaned monsters" §L names as the failure that ends this feature's
+ // credibility.
+ const id = seedRun({ status: 'running', cleanupStatus: 'pending', steps: [{ status: 'pending' }] })
+ store.unresolved[id] = 3
+
+ const result = await controls.cancel(id, { cleanup: false, reason: 'leave it up' }, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.equal(result.cleanup, false)
+ assert.equal(runRow(id).cleanup_status, 'incomplete')
+ assert.equal(store.log.at(-1).detail.cleanup, false)
+ assert.equal(store.log.at(-1).detail.by, ACTOR)
+})
+
+test('a run that recorded nothing is unaffected by either flag', async () => {
+ // `not_required` is not walked to `incomplete` by a cancel that skipped a
+ // teardown there was nothing to do — and it is the LEDGER that says so, not the
+ // status column, because `not_required` is also what a run holding only a lease
+ // wrongly carried before the live walk found it.
+ const id = seedRun({ status: 'running', cleanupStatus: 'not_required', steps: [{ status: 'pending' }] })
+ store.unresolved[id] = 0
+ await controls.cancel(id, { cleanup: false }, ACTOR)
+ assert.equal(runRow(id).cleanup_status, 'not_required')
+})
+
+// ── cleanup, the eighth control ────────────────────────────────────────────
+
+test('cleanup re-runs the teardown and clears the attempt counter', async () => {
+ // The manual retry §L promises. `resetAttempts` is the licence a human has and
+ // the automatic sweep does not — Engagement Phase 14's rule, whose defect was
+ // a sweep that reset every stale row and made the attempt ceiling unreachable.
+ const id = seedRun({ status: 'completed', cleanupStatus: 'incomplete' })
+
+ const result = await controls.cleanupRun(id, ACTOR)
+
+ assert.equal(result.ok, true)
+ assert.deepEqual(store.sweeps, [{ runId: id, resetAttempts: true, actor: ACTOR }])
+ assert.equal(result.summary.reverted, 1)
+})
+
+test('cleanup refuses a run that is still in flight', async () => {
+ // A run still going has a ledger that is still growing, and reverting a
+ // resource the next step is about to use would be core undoing an event while
+ // it is happening. Cancel is the control for a run that should stop.
+ for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
+ const id = seedRun({ status, cleanupStatus: 'pending' })
+ const result = await controls.cleanupRun(id, ACTOR)
+ assert.equal(result.ok, false, status)
+ assert.match(result.errors[0], /cancel it before cleaning up after it/)
+ }
+ assert.deepEqual(store.sweeps, [])
+})
+
+test('cleanup refuses a run that recorded no resources', async () => {
+ const id = seedRun({ status: 'completed', cleanupStatus: 'not_required' })
+ const result = await controls.cleanupRun(id, ACTOR)
+ assert.equal(result.ok, false)
+ assert.match(result.errors[0], /nothing to give back/)
+})
+
+test('cleanup on an unknown run is a 404, not a 409', async () => {
+ const result = await controls.cleanupRun(9999, ACTOR)
+ assert.equal(result.status, 404)
+})
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
index d441766..e14efa4 100644
--- a/server/test/eventRunner.test.js
+++ b/server/test/eventRunner.test.js
@@ -45,6 +45,11 @@ const gatesDb = require('../src/model/events/eventPhaseGates.db')
// every file that stubs the layer under it needs the stub.
const settingsDb = require('../src/model/events/eventActionSettings.db')
const budgetDb = require('../src/model/events/eventRunBudget.db')
+// Phase 8 put a ledger write in front of every world-changing dispatch and a
+// cleanup leg at the end of the tick. **Fourth time, same rule, and this file's
+// own note is what caught it**: unstubbed, one of these is not a wrong answer,
+// it is a ten-second wait on the dead port.
+const resourcesDb = require('../src/model/events/eventRunResources.db')
const gates = require('../src/events/gates')
const db = require('../src/utils/db')
@@ -58,7 +63,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
let store
const originals = {}
-for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb]]) {
+for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb], ['resourcesDb', resourcesDb]]) {
originals[name] = { mod, fns: { ...mod } }
}
@@ -79,8 +84,10 @@ function installStubs() {
gates: new Map(),
settings: new Map(),
budget: new Map(),
+ resources: new Map(),
nextStepId: 1,
nextGateId: 1,
+ nextResourceId: 1,
}
// Phase 4 put a schedule-expansion leg in front of the tick. This file is
@@ -91,6 +98,69 @@ function installStubs() {
// connection timeout.
Object.assign(definitionsDb, { findSchedulable: async () => [] })
+ // ── The resource ledger (Phase 8) ──
+ //
+ // `reserve` enforces `uq_evres_target` in the stub, because the refusal it
+ // produces is BEHAVIOUR the runner branches on rather than an implementation
+ // detail: a placeholder that collides is this step's own earlier attempt and is
+ // reused, and a lease that collides is another run holding the target. A stub
+ // that let both inserts through would make the retry path grow a second row and
+ // the conflict test pass for no reason.
+ const HELD_STATUSES = ['pending', 'confirmed', 'reverting']
+ resourcesDb.reserve = async ({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) => {
+ const holder = [...store.resources.values()].find(
+ (r) => r.owner_module === owner && r.kind === kind && r.ref === ref && HELD_STATUSES.includes(r.status),
+ )
+ if (holder) return { ok: false, code: 'held', holder: { run_id: holder.run_id, status: holder.status } }
+ const id = store.nextResourceId++
+ store.resources.set(id, {
+ id,
+ run_id: runId,
+ step_id: stepId,
+ owner_module: owner,
+ kind,
+ ref,
+ payload,
+ lease_until: leaseUntil,
+ status: 'pending',
+ revert_attempts: 0,
+ last_error: null,
+ member_key: memberKey,
+ })
+ return { ok: true, id }
+ }
+ resourcesDb.confirm = async (id) => {
+ const r = store.resources.get(id)
+ if (!r || r.status !== 'pending') return false
+ r.status = 'confirmed'
+ return true
+ }
+ resourcesDb.resolvePlaceholder = async (id) => {
+ const r = store.resources.get(id)
+ if (!r || r.kind !== resourcesDb.STEP_KIND || !['pending', 'confirmed'].includes(r.status)) return false
+ r.status = 'reverted'
+ return true
+ }
+ resourcesDb.findByTarget = async (owner, kind, ref) =>
+ [...store.resources.values()].reverse().find((r) => r.owner_module === owner && r.kind === kind && r.ref === ref) || null
+ resourcesDb.forRun = async (runId) => [...store.resources.values()].filter((r) => r.run_id === runId)
+ resourcesDb.markReverted = async (id) => {
+ const r = store.resources.get(id)
+ if (r) r.status = 'reverted'
+ }
+ // The cleanup leg's scan. This file is about the runner's own legs, and the
+ // sweep has its own file — answering with nothing is what keeps every `tick()`
+ // here measuring the runner rather than a teardown.
+ resourcesDb.runsNeedingCleanup = async () => []
+
+ runsDb.setCleanupStatus = async (id, to, from = null) => {
+ const r = store.runs.get(id)
+ if (!r) return false
+ if (from && !from.includes(r.cleanup_status)) return false
+ r.cleanup_status = to
+ return true
+ }
+
// Snapshots, not live references. A SQL SELECT hands back a copy, and the
// runner reads `step.attempts` as the value BEFORE its own claim incremented
// it — returning references here would make the retry budget off by one in the
@@ -1319,3 +1389,163 @@ test('the runner never re-checks the role of whoever started the run', async ()
assert.equal(stepsOf(id)[0].status, 'done')
})
+
+// ── The resource ledger, from the runner's side (Phase 8) ──────────────────
+//
+// `eventLedger.test.js` owns the recording RULES and `eventCleanup.test.js` owns
+// the undo. What belongs here is the ORDER — that the placeholder is written
+// before the module is reached and after the permission check, and that a step
+// which never answers leaves it standing. Those are properties of `drainStep`,
+// and nothing below the runner can observe them.
+
+const ledgerRows = (id) => [...store.resources.values()].filter((r) => r.run_id === id)
+
+test('a world-changing step is recorded BEFORE it is dispatched', async () => {
+ // §D rule 1. The assertion is made from INSIDE `perform()`, which is the only
+ // place that can tell "recorded first" from "recorded at all" — and the
+ // difference between the two is every object whose acknowledgement is lost.
+ let seenDuringDispatch = null
+ register([scriptedAction('test.spawn', { risk: 'change', reversible: 'ledger', revert: async () => ({ ok: true }) })])
+ setSwitch('test.spawn', true)
+ const id = seedRun([{ key: 'main', steps: [step('test.spawn')] }])
+ scripted['test.spawn'] = {
+ calls: [],
+ answer: () => {
+ seenDuringDispatch = ledgerRows(id).map((r) => [r.kind, r.status])
+ return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
+ },
+ }
+
+ await runner.tick(T0)
+
+ assert.deepEqual(seenDuringDispatch, [['@step', 'pending']])
+ // And on the answer the real row exists and the placeholder is done with.
+ assert.deepEqual(
+ ledgerRows(id).map((r) => [r.kind, r.ref, r.status]),
+ [
+ ['@step', stepsOf(id)[0].idempotency_key, 'reverted'],
+ ['creature', '0x40001234', 'confirmed'],
+ ],
+ )
+ assert.equal(run(id).cleanup_status, 'pending')
+ assert.ok(kinds(id).includes('resource.recorded'))
+})
+
+test('a step that never answers leaves its placeholder pending', async () => {
+ // The whole reason the placeholder exists. The module timed out, so core does
+ // not know whether anything was created — and the row that survives is what
+ // lets cleanup ask it by idempotency key later. Record on the answer instead
+ // and this run finishes looking spotless over a shard full of orphans.
+ register([
+ scriptedAction('test.spawn', {
+ risk: 'change',
+ reversible: 'ledger',
+ budgetMs: 5,
+ revert: async () => ({ ok: true }),
+ perform: () => new Promise(() => {}),
+ }),
+ ])
+ setSwitch('test.spawn', true)
+ const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
+
+ await runner.tick(T0)
+
+ assert.deepEqual(ledgerRows(id).map((r) => [r.kind, r.status]), [['@step', 'pending']])
+})
+
+test('a refused step ledgers nothing, because it never reached the module', async () => {
+ // The placeholder is written AFTER the permission check, deliberately. A step
+ // refused by a cap or by a switch created nothing, and a ledger row for it
+ // would be core asking a module to undo something it was never asked to do.
+ register([scriptedAction('test.spawn', { risk: 'change', reversible: 'ledger', revert: async () => ({ ok: true }) })])
+ // No `setSwitch`, so it is default-off: §K's world-changing default.
+ const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
+
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'refused')
+ assert.deepEqual(ledgerRows(id), [])
+ assert.equal(run(id).cleanup_status, 'not_required')
+})
+
+test('an announce step ledgers nothing at all', async () => {
+ // `reversible: 'none'`, so there is nothing core could come back for. A
+ // placeholder here would be a row teardown could never resolve — which is the
+ // reason `core.announce` is declared `none` rather than `ledger` even though a
+ // sent message cannot be unsent.
+ register([scriptedAction('test.say')])
+ const id = seedRun([{ key: 'main', steps: [step('test.say')] }])
+ await runner.tick(T0)
+ assert.equal(run(id).status, 'completed')
+ assert.deepEqual(ledgerRows(id), [])
+ assert.equal(run(id).cleanup_status, 'not_required')
+})
+
+test('a parked step records what it made, because its confirm never dispatches again', async () => {
+ // `await: 'human'` is a SUCCESS: the module did its part and something outside
+ // the system has to happen next. The cue's confirm finishes the step without a
+ // second dispatch, so this is the only moment its resources can be recorded.
+ register([
+ scriptedAction('test.stage', { risk: 'change', reversible: 'ledger', revert: async () => ({ ok: true }) }),
+ ])
+ setSwitch('test.stage', true)
+ const id = seedRun([{ key: 'main', steps: [step('test.stage')] }])
+ scripted['test.stage'] = {
+ calls: [],
+ answer: { ok: true, await: 'human', resources: [{ kind: 'prop', ref: 'gate-1' }] },
+ }
+
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'running')
+ assert.deepEqual(
+ ledgerRows(id).map((r) => [r.kind, r.status]),
+ [['@step', 'reverted'], ['prop', 'confirmed']],
+ )
+})
+
+test('a retry re-uses its placeholder and records the resources once', async () => {
+ // An idempotency key does not vary by attempt (§E), so the second attempt's
+ // placeholder insert collides with the first attempt's row — and a module that
+ // honestly re-reports the same creature must not produce a second thing for
+ // cleanup to revert.
+ register([
+ scriptedAction('test.spawn', { risk: 'change', reversible: 'ledger', revert: async () => ({ ok: true }) }),
+ ])
+ setSwitch('test.spawn', true)
+ const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
+ scripted['test.spawn'] = {
+ calls: [],
+ answers: [
+ { ok: false, error: 'the shard did not answer' },
+ { ok: true, resources: [{ kind: 'creature', ref: '0xFEED' }] },
+ ],
+ }
+
+ await runner.tick(T0)
+ await runner.tick(later(runner.RETRY_MS + 1000))
+
+ assert.equal(stepsOf(id)[0].status, 'done')
+ assert.equal(ledgerRows(id).filter((r) => r.kind === '@step').length, 1)
+ assert.equal(ledgerRows(id).filter((r) => r.kind === 'creature').length, 1)
+})
+
+test('a ledger write that fails stops the dispatch rather than losing the record', async () => {
+ // The ledger is what makes a world write recoverable, so a step that cannot be
+ // recorded must not be sent. Transient, because the alternative is an
+ // unrecorded world change — the one outcome §D rule 1 exists to make impossible.
+ register([
+ scriptedAction('test.spawn', { risk: 'change', reversible: 'ledger', revert: async () => ({ ok: true }) }),
+ ])
+ setSwitch('test.spawn', true)
+ const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
+ scripted['test.spawn'] = { calls: [], answer: { ok: true } }
+ resourcesDb.reserve = async () => {
+ throw new Error('the ledger is unreachable')
+ }
+
+ await runner.tick(T0)
+
+ assert.equal(scripted['test.spawn'].calls.length, 0, 'the module must not have been reached')
+ assert.match(stepsOf(id)[0].last_error, /the resource ledger could not record this step/)
+})
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
index ac66978..ce69605 100644
--- a/server/test/eventRunnerSql.test.js
+++ b/server/test/eventRunnerSql.test.js
@@ -80,6 +80,18 @@
// that died between entering a phase and opening its gate opens no second
// one on the next tick.
//
+// **Phase 8 added the resource ledger**, and its unique key is the single most
+// server-dependent thing in this feature. "Two events cannot hold a lease on one
+// target" has to hold among LIVE rows only — last week's finished event must not
+// keep this week's from leasing the same rate — and MariaDB has no partial index,
+// so the encoding is a STORED generated column that goes NULL once the row is no
+// longer ours. Whether multiple NULLs collide in a unique index is a property of
+// the server and of nothing else, and TEAMS.md §2.5 already had to be corrected
+// once on this exact shape: MariaDB refuses ON DELETE SET NULL on a foreign key
+// whose column is a base column of a stored generated column (error 1901), which
+// is why the expression reads `status` alone and `step_id` stays a SET NULL FK.
+// Both halves of that are proved below rather than believed.
+//
// Plus the two unique indexes that are load-bearing rather than tidy:
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
@@ -208,6 +220,29 @@ CREATE TABLE event_run_budget (
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
UNIQUE KEY uq_evbud_dim (run_id, dimension)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE event_run_resources (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ run_id BIGINT NOT NULL,
+ step_id BIGINT NULL,
+ owner_module VARCHAR(64) NOT NULL,
+ kind VARCHAR(64) NOT NULL,
+ ref VARCHAR(190) NOT NULL,
+ payload JSON NULL,
+ lease_until DATETIME NULL,
+ status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
+ NOT NULL DEFAULT 'pending',
+ revert_attempts INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ member_key VARCHAR(190) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
+ CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
+ CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
+ UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
+ INDEX idx_evres_run (run_id, status),
+ INDEX idx_evres_live (status, lease_until)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_action_settings (
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 0,
@@ -438,6 +473,7 @@ const rows = (r) => Number(r.affectedRows)
beforeEach(async () => {
if (!available) return
await pool.query('DELETE FROM event_run_phase_gates')
+ await pool.query('DELETE FROM event_run_resources')
await pool.query('DELETE FROM event_run_steps')
await pool.query('DELETE FROM event_runs')
await pool.query('DELETE FROM event_definitions')
@@ -1139,7 +1175,21 @@ test('a gate goes with its run', async (t) => {
// live server, so the pool holds open connections and the process never exits —
// 49 green tests and a file that hangs until the harness kills it. Every other
// event test file already closes it in an `after`; this one now has a reason to.
+//
+// **And that second pool has to be aimed at the throwaway database**, which it
+// was not before Phase 8 noticed. `utils/db` builds its pool at REQUIRE time from
+// `DB_NAME`, and its own `dotenv.config()` reads `server/.env` — so a model test
+// run on a developer's machine was reaching that developer's real schema while
+// the fixtures it was asserting against were being written next door. It passed
+// only because the two tables happened to exist in both. `dotenv` does not
+// overwrite a variable that already exists, so setting it here, before the
+// require below, is what beats the file. This file drops its database in `after`,
+// so aiming the model pool at it is also what keeps the whole run disposable.
+process.env.DB_NAME = DB
+
const budgetDb = require('../src/model/events/eventRunBudget.db')
+const resourcesDb = require('../src/model/events/eventRunResources.db')
+const runsDb = require('../src/model/events/eventRuns.db')
const appDb = require('../src/utils/db')
after(() => appDb.close())
@@ -1303,3 +1353,324 @@ test('a non-positive spend never reaches the database', async (t) => {
assert.equal(await budgetDb.spend(runId, 'uo.creatures', 0), true)
assert.equal(await consumedOf(runId), 0)
})
+
+// ── The resource ledger's unique key (Phase 8) ─────────────────────────────
+//
+// Every test here is about a property of the SERVER. A stub can enforce whatever
+// rule its author had in mind; only MariaDB can say whether this encoding of
+// "unique among live rows" actually is one.
+
+const insertResource = async (runId, over = {}) =>
+ (
+ await pool.query(
+ `INSERT INTO event_run_resources (run_id, step_id, owner_module, kind, ref, payload, lease_until, member_key, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ runId,
+ over.stepId ?? null,
+ over.owner ?? 'demo',
+ over.kind ?? 'creature',
+ over.ref ?? '0xA',
+ over.payload ?? null,
+ over.leaseUntil ?? null,
+ over.memberKey ?? null,
+ over.status ?? 'pending',
+ ],
+ )
+ ).insertId
+
+const dup = async (fn) => {
+ try {
+ await fn()
+ return null
+ } catch (err) {
+ return err.code || String(err.errno)
+ }
+}
+
+test('two runs cannot hold one target, and the refusal comes from the server', async (t) => {
+ if (needDb(t)) return
+ // §D: "the unique index is what stops two events leasing one target." Not a
+ // read-then-insert — two runs entering the same tick would both pass the check
+ // — so the whole conflict story is this one constraint answering.
+ const a = await seedRun()
+ const b = await seedRun()
+ await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status: 'confirmed' })
+ const code = await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' }))
+ assert.equal(code, 'ER_DUP_ENTRY')
+})
+
+test('the key is released by the three statuses that mean it is no longer ours', async (t) => {
+ if (needDb(t)) return
+ // Amended 2026-09-03. §D says "among non-reverted rows", which was written
+ // before the six statuses had their meanings; taken literally it makes
+ // `drifted` and `orphaned` hold a target for ever, so one bad night would
+ // disable a lease permanently with no control able to clear it. `drifted` means
+ // somebody else has hold of the value and this run has let go; `orphaned` means
+ // it vanished. Neither is a claim on the target.
+ for (const status of ['reverted', 'drifted', 'orphaned']) {
+ await pool.query('DELETE FROM event_run_resources')
+ const a = await seedRun()
+ const b = await seedRun()
+ await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status })
+ const code = await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' }))
+ assert.equal(code, null, `a ${status} row must not hold the target`)
+ }
+})
+
+test('the key is HELD by the three that mean core still believes it is ours', async (t) => {
+ if (needDb(t)) return
+ for (const status of ['pending', 'confirmed', 'reverting']) {
+ await pool.query('DELETE FROM event_run_resources')
+ const a = await seedRun()
+ const b = await seedRun()
+ await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status })
+ const code = await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' }))
+ assert.equal(code, 'ER_DUP_ENTRY', `a ${status} row must hold the target`)
+ }
+})
+
+test('many released rows on one target coexist, which is the whole encoding', async (t) => {
+ if (needDb(t)) return
+ // The property the NULL depends on: multiple NULLs do not collide in a unique
+ // index. A weekly event that leases the same rate every Saturday accumulates one
+ // released row per week, and the fiftieth must not fail to insert.
+ const a = await seedRun()
+ for (let i = 0; i < 5; i++) {
+ await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status: 'reverted' })
+ }
+ const held = await pool.query(
+ 'SELECT COUNT(*) AS n FROM event_run_resources WHERE ref = ? AND live_marker IS NULL',
+ ['demo.rate'],
+ )
+ assert.equal(Number(held[0].n), 5)
+ // And a live one still goes on top of them.
+ assert.equal(await dup(() => insertResource(a.runId, { kind: 'override', ref: 'demo.rate' })), null)
+})
+
+test('an UPDATE that releases a row frees the target at once', async (t) => {
+ if (needDb(t)) return
+ // The generated column is STORED, so this is really asking whether MariaDB
+ // recomputes it on UPDATE and re-indexes. Cleanup depends on it entirely: the
+ // moment a lease is restored, the next event may take it.
+ const a = await seedRun()
+ const b = await seedRun()
+ const id = await insertResource(a.runId, { kind: 'override', ref: 'demo.rate', status: 'confirmed' })
+ assert.equal(await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' })), 'ER_DUP_ENTRY')
+ await pool.query("UPDATE event_run_resources SET status = 'reverted' WHERE id = ?", [id])
+ assert.equal(await dup(() => insertResource(b.runId, { kind: 'override', ref: 'demo.rate' })), null)
+})
+
+test('the key is per owner and per kind, not per ref', async (t) => {
+ if (needDb(t)) return
+ // `kind` and `ref` are module-opaque strings core stores verbatim, so two
+ // modules using the same word for different things must not collide — and one
+ // module's `creature:0xA` and `item:0xA` are two objects.
+ const a = await seedRun()
+ await insertResource(a.runId, { owner: 'demo', kind: 'creature', ref: '0xA', status: 'confirmed' })
+ assert.equal(await dup(() => insertResource(a.runId, { owner: 'other', kind: 'creature', ref: '0xA' })), null)
+ assert.equal(await dup(() => insertResource(a.runId, { owner: 'demo', kind: 'item', ref: '0xA' })), null)
+ assert.equal(await dup(() => insertResource(a.runId, { owner: 'demo', kind: 'creature', ref: '0xB' })), null)
+})
+
+test('a deleted step leaves its resources behind, and a deleted run does not', async (t) => {
+ if (needDb(t)) return
+ // TEAMS.md §2.5's correction, held as a test: `step_id` is SET NULL and it only
+ // works because the generated column reads `status` alone. If `step_id` were in
+ // that expression MariaDB would refuse the constraint outright (error 1901), and
+ // the migration would fail on a fresh install rather than here.
+ //
+ // The two directions are different on purpose. A record of what was changed in
+ // the WORLD must outlive the row that scheduled it — `engagement_sends`'
+ // argument — while a deleted RUN takes its ledger with it, because the ledger
+ // exists to answer questions about a run.
+ const a = await seedRun()
+ const stepId = await seedStep(a.runId)
+ const id = await insertResource(a.runId, { stepId, status: 'confirmed' })
+
+ await pool.query('DELETE FROM event_run_steps WHERE id = ?', [stepId])
+ const orphaned = (await pool.query('SELECT step_id, status FROM event_run_resources WHERE id = ?', [id]))[0]
+ assert.equal(orphaned.step_id, null)
+ assert.equal(orphaned.status, 'confirmed')
+
+ await pool.query('DELETE FROM event_runs WHERE id = ?', [a.runId])
+ const gone = await pool.query('SELECT id FROM event_run_resources WHERE id = ?', [id])
+ assert.equal(gone.length, 0)
+})
+
+test('the ledger model reserves, confirms, reverts and refuses for real', async (t) => {
+ if (needDb(t)) return
+ // Through the shipping module rather than a copy of its statements, like the
+ // budget tests above: `reserve` reads ER_DUP_ENTRY as a refusal and looks the
+ // holder up to name it, and both halves of that are the connector's behaviour
+ // rather than this file's.
+ const a = await seedRun()
+ const b = await seedRun()
+
+ const first = await resourcesDb.reserve({ runId: a.runId, owner: 'demo', kind: 'override', ref: 'demo.rate' })
+ assert.equal(first.ok, true)
+ await resourcesDb.confirm(first.id)
+
+ const second = await resourcesDb.reserve({ runId: b.runId, owner: 'demo', kind: 'override', ref: 'demo.rate' })
+ assert.equal(second.ok, false)
+ assert.equal(second.code, 'held')
+ assert.equal(Number(second.holder.run_id), Number(a.runId))
+ assert.equal(second.holder.status, 'confirmed')
+
+ // And once it is given back the second run gets it.
+ await resourcesDb.markReverted(first.id)
+ const third = await resourcesDb.reserve({ runId: b.runId, owner: 'demo', kind: 'override', ref: 'demo.rate' })
+ assert.equal(third.ok, true)
+})
+
+test('failRevert increments and NEVER resets, and only a human clears it', async (t) => {
+ if (needDb(t)) return
+ // Engagement Phase 14's rule at the statement level: `revert_attempts =
+ // revert_attempts + 1` is written in one place, and the reset is a separate
+ // statement with an actor behind it. A `SET revert_attempts = ?` anywhere in
+ // the sweep would make the ceiling unreachable and the row cycle for ever.
+ const a = await seedRun()
+ const id = await resourcesDb
+ .reserve({ runId: a.runId, owner: 'demo', kind: 'creature', ref: '0xA' })
+ .then((r) => r.id)
+ await resourcesDb.confirm(id)
+
+ await resourcesDb.failRevert(id, 'the shard did not answer')
+ await resourcesDb.failRevert(id, 'still nothing')
+ let row = (await pool.query('SELECT * FROM event_run_resources WHERE id = ?', [id]))[0]
+ assert.equal(Number(row.revert_attempts), 2)
+ assert.equal(row.status, 'confirmed')
+ assert.equal(row.last_error, 'still nothing')
+
+ assert.equal(await resourcesDb.resetAttempts(a.runId), 1)
+ row = (await pool.query('SELECT * FROM event_run_resources WHERE id = ?', [id]))[0]
+ assert.equal(Number(row.revert_attempts), 0)
+})
+
+test('claimRevert is a compare-and-set, and reverting is not re-claimable', async (t) => {
+ if (needDb(t)) return
+ // The cleanup leg and the manual cleanup route can both be working one run at
+ // once. `reverting` is deliberately not claimable — a row another pass is
+ // mid-revert on is left alone, exactly as a step with a live claim is.
+ const a = await seedRun()
+ const id = await resourcesDb
+ .reserve({ runId: a.runId, owner: 'demo', kind: 'creature', ref: '0xA' })
+ .then((r) => r.id)
+ await resourcesDb.confirm(id)
+
+ assert.equal(await resourcesDb.claimRevert(id), true)
+ assert.equal(await resourcesDb.claimRevert(id), false, 'a second pass must not take a row mid-revert')
+
+ await resourcesDb.markReverted(id)
+ assert.equal(await resourcesDb.claimRevert(id), false, 'and a reverted row is finished')
+})
+
+test('the placeholder is resolvable exactly once, and only if it is a placeholder', async (t) => {
+ if (needDb(t)) return
+ // The `kind = '@step'` guard in the statement, not in JavaScript. It is what
+ // stops a bug elsewhere resolving a real resource — which would be core marking
+ // a live creature as given back without asking anyone.
+ const a = await seedRun()
+ const placeholder = await resourcesDb
+ .reserve({ runId: a.runId, owner: 'demo', kind: resourcesDb.STEP_KIND, ref: 'k'.repeat(40) })
+ .then((r) => r.id)
+ const real = await resourcesDb
+ .reserve({ runId: a.runId, owner: 'demo', kind: 'creature', ref: '0xA' })
+ .then((r) => r.id)
+
+ assert.equal(await resourcesDb.resolvePlaceholder(placeholder), true)
+ assert.equal(await resourcesDb.resolvePlaceholder(placeholder), false)
+ assert.equal(await resourcesDb.resolvePlaceholder(real), false)
+ const stillThere = (await pool.query('SELECT status FROM event_run_resources WHERE id = ?', [real]))[0]
+ assert.equal(stillThere.status, 'pending')
+})
+
+test('the unresolved reads see the five statuses that still want something', async (t) => {
+ if (needDb(t)) return
+ // `cleanup_status` is derived from this count, so what it includes IS the
+ // definition of "clean". A `drifted` row left out of it would let a run whose
+ // lease somebody else took call itself complete.
+ const a = await seedRun()
+ for (const status of ['pending', 'confirmed', 'reverting', 'reverted', 'orphaned', 'drifted']) {
+ await insertResource(a.runId, { ref: `ref-${status}`, status })
+ }
+ assert.equal(await resourcesDb.unresolvedCount(a.runId), 5)
+ const rows = await resourcesDb.unresolvedForRun(a.runId)
+ assert.deepEqual(
+ rows.map((r) => r.status).sort(),
+ ['confirmed', 'drifted', 'orphaned', 'pending', 'reverting'],
+ )
+ const counts = await resourcesDb.unresolvedCounts([a.runId])
+ assert.equal(counts.get(a.runId) ?? counts.get(String(a.runId)), 5)
+})
+
+test('payload comes back as an object rather than as a string', async (t) => {
+ if (needDb(t)) return
+ // A lease's baseline lives in here and the cleanup sweep reads it out to pass
+ // to `restore`. The connector hands JSON back as text, so a missing hydration
+ // is a `restore(undefined)` — a lease put back to nothing, silently.
+ const a = await seedRun()
+ const id = await resourcesDb
+ .reserve({
+ runId: a.runId,
+ owner: 'demo',
+ kind: 'override',
+ ref: 'demo.rate',
+ payload: { baseline: 1, applied: 3 },
+ })
+ .then((r) => r.id)
+ void id
+ const [row] = await resourcesDb.forRun(a.runId)
+ assert.deepEqual(row.payload, { baseline: 1, applied: 3 })
+})
+
+test('the cleanup scan finds a run that owes something, whatever its status column says', async (t) => {
+ if (needDb(t)) return
+ await pool.query('ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS cleanup_status ' +
+ "ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required'")
+
+ // **`not_required` is in the scan because of a live-walk defect**, not for
+ // tidiness. A run whose only resource was a LEASE never went through the
+ // ledger's `markRunDirty` — `core.lease` reserves its own row — so its column
+ // stayed `not_required` and the lease was never given back at all. A terminal
+ // run with an unresolved row has work to do whatever any summary column says.
+ const lease = await seedRun({ status: 'completed' })
+ await insertResource(lease.runId, { kind: 'override', ref: 'demo.rate', status: 'confirmed' })
+ const marked = await seedRun({ status: 'cancelled' })
+ await insertResource(marked.runId, { ref: '0xB', status: 'pending' })
+ await pool.query("UPDATE event_runs SET cleanup_status = 'pending' WHERE id = ?", [marked.runId])
+
+ // Three that must NOT be selected, one per reason.
+ const running = await seedRun({ status: 'running' })
+ await insertResource(running.runId, { ref: '0xC', status: 'confirmed' })
+ const done = await seedRun({ status: 'completed' })
+ await insertResource(done.runId, { ref: '0xD', status: 'reverted' })
+ const givenUp = await seedRun({ status: 'completed' })
+ await insertResource(givenUp.runId, { ref: '0xE', status: 'confirmed' })
+ await pool.query("UPDATE event_runs SET cleanup_status = 'incomplete' WHERE id = ?", [givenUp.runId])
+
+ const found = (await resourcesDb.runsNeedingCleanup(10, 3)).map((r) => Number(r.id))
+ assert.deepEqual(found.sort(), [Number(lease.runId), Number(marked.runId)].sort())
+})
+
+test('the scan stops selecting a run whose attempts are spent', async (t) => {
+ if (needDb(t)) return
+ // Without the bound in the join, a run whose rows are all spent would be
+ // selected, worked over and found to have nothing to do on every tick for the
+ // rest of its life.
+ const a = await seedRun({ status: 'completed' })
+ const id = await insertResource(a.runId, { status: 'confirmed' })
+ await pool.query("UPDATE event_runs SET cleanup_status = 'pending' WHERE id = ?", [a.runId])
+ assert.equal((await resourcesDb.runsNeedingCleanup(10, 3)).length, 1)
+
+ await pool.query('UPDATE event_run_resources SET revert_attempts = 3 WHERE id = ?', [id])
+ assert.equal((await resourcesDb.runsNeedingCleanup(10, 3)).length, 0)
+})
+
+test('setCleanupStatus is guarded, which is what stops a late row re-opening a swept run', async (t) => {
+ if (needDb(t)) return
+ const a = await seedRun({ status: 'completed' })
+ assert.equal(await runsDb.setCleanupStatus(a.runId, 'complete'), true)
+ assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending', ['not_required']), false)
+ assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending'), true)
+})
diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js
index ff3179d..0b77599 100644
--- a/server/test/eventsAdmin.test.js
+++ b/server/test/eventsAdmin.test.js
@@ -42,6 +42,13 @@ const logDb = require('../src/model/events/eventRunLog.db')
// model needs a stub in every file that stubs that layer.
const settingsDb = require('../src/model/events/eventActionSettings.db')
const budgetDb = require('../src/model/events/eventRunBudget.db')
+// Phase 8: the run console reads the resource ledger. The SAME rule, for the
+// fourth time in this feature -- Phase 4's expansion leg, Phase 5's gate read,
+// Phase 6's settings read and now this one. **Unstubbed it is not a wrong
+// answer, it is a ten-second ECONNREFUSED against the dead port**, which is why
+// one missing stub here cost the run detail test ten seconds and said nothing
+// about the route it was testing.
+const resourcesDb = require('../src/model/events/eventRunResources.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
const activity = require('../src/model/activity/activity.model')
@@ -63,6 +70,7 @@ for (const [name, mod] of [
['gatesDb', gatesDb],
['settingsDb', settingsDb],
['budgetDb', budgetDb],
+ ['resourcesDb', resourcesDb],
['activity', activity],
]) {
originals[name] = { mod, fns: { ...mod } }
@@ -88,6 +96,7 @@ function installStubs() {
gates: [],
settings: new Map(),
budget: new Map(),
+ resources: [],
occurrences: new Set(),
nextDefinition: 1,
nextVersion: 1,
@@ -341,6 +350,9 @@ function installStubs() {
[...store.budget.values()]
.filter((b) => Number(b.run_id) === Number(runId))
.sort((a, b) => a.dimension.localeCompare(b.dimension))
+
+ resourcesDb.forRun = async (runId) =>
+ store.resources.filter((r) => Number(r.run_id) === Number(runId))
}
// ── Fixtures ───────────────────────────────────────────────────────────────
@@ -415,23 +427,26 @@ test('the catalog serves the registry, callables stripped, with its vocabularies
assert.equal(res.statusCode, 200)
assert.deepEqual(
res.body.actions.map((a) => a.id),
- ['core.announce', 'core.wait', 'core.cue'],
+ ['core.announce', 'core.wait', 'core.cue', 'core.lease'],
)
for (const action of res.body.actions) assert.equal(action.perform, undefined)
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
// The other three registrations of the module contract arrived in Phase 7, and
// they are served BESIDE the actions because the editor needs all four to draw
- // one step. Core declares no budgets and no leases of its own — its three
- // actions cost nothing and hold nothing — so those are empty here, and that is
- // the fact worth asserting: present and empty, not absent.
+ // one step. Core declares no budgets and no leases of its own — its four
+ // actions cost nothing, and `core.lease` BORROWS a lease rather than declaring
+ // one, which is the seam working the way round it is meant to: core owns the
+ // verb, a module owns the value. So both are empty here, and that is the fact
+ // worth asserting: present and empty, not absent.
assert.deepEqual(res.body.budgets, [])
assert.deepEqual(res.body.leases, [])
- // One option source, and it is core's: `core.announce`'s leg param. It is here
- // WITHOUT its resolver — the values are a request of their own.
+ // Two option sources, both core's: `core.announce`'s leg and `core.lease`'s
+ // lease. They are here WITHOUT their resolvers — the values are a request of
+ // their own.
assert.deepEqual(
res.body.optionSources.map((s) => s.id),
- ['core.options.legs'],
+ ['core.options.legs', 'core.options.leases'],
)
for (const s of res.body.optionSources) assert.equal(s.resolve, undefined)
})
@@ -824,7 +839,12 @@ test('the board serves every registered action with its risk-class default, and
assert.equal(res.statusCode, 200)
const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a]))
- assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.wait'])
+ assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.lease', 'core.wait'])
+ // And `core.lease` is the one core action the default-off rule bites: it is
+ // `change`, so a fresh deployment cannot borrow a value until an admin says so.
+ // §K's sentence, applied to core's own verb rather than only to a module's.
+ assert.equal(byId['core.lease'].enabled, false)
+ assert.equal(byId['core.lease'].changesWorld, true)
// core.wait is `inspect`, and it arrives ENABLED. Read §K's sentence literally
// and it would not, and every published event that waits would break on a fresh
// deployment (org lead, 2026-09-03).
@@ -843,6 +863,7 @@ test('the board never serves a callable', async () => {
for (const a of res.body.actions) {
assert.equal(a.perform, undefined)
assert.equal(a.revert, undefined)
+ assert.equal(a.reconcile, undefined)
assert.equal(a.cost, undefined)
}
})
diff --git a/server/test/eventsRoles.test.js b/server/test/eventsRoles.test.js
index 9abd2dc..d0d64f5 100644
--- a/server/test/eventsRoles.test.js
+++ b/server/test/eventsRoles.test.js
@@ -162,6 +162,11 @@ const SURFACE = [
// Phase 6's switchboard — configuration that can break things.
['GET', '/events/actions', ['admin']],
['PUT', '/events/actions', ['admin']],
+ // Phase 8's cleanup, and it sits in the ADMIN column rather than with the live
+ // controls it is rendered beside. Re-running a teardown is not incident
+ // response — it asks core to write to the world again, which §K puts in the
+ // same row as the world-changing actions themselves.
+ ['POST', '/events/runs/1/cleanup', ['admin']],
// Live control of a run in flight: admin and moderator, deliberately WIDER
// than start.
@@ -214,6 +219,19 @@ test('an editor may price an event but not publish or start it', async () => {
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
})
+test('a moderator may stop a run but not re-run its cleanup', async () => {
+ // The same shape as start-and-stop above, one row further on, and held as its
+ // own claim for the same reason: the two controls sit next to each other on the
+ // run console and a later tidying pass that gave them one gate would have to
+ // delete an assertion that says why they do not share one.
+ //
+ // Cancelling is the 2am incident. Cleanup asks core to delete things in a live
+ // world, which is the narrower decision even though it is the tidier-sounding
+ // button.
+ assert.equal(await forbidden('POST', '/events/runs/1/cancel', 'moderator'), false)
+ assert.equal(await forbidden('POST', '/events/runs/1/cleanup', 'moderator'), true)
+})
+
test('the switchboard is admin only in both directions', async () => {
// Reading which actions are enabled is as much `admin` as writing it: the board
// is the deployment's posture, and §K puts it in the same row as the actions it
--
2.49.1
From 82a50e5e04617bd3817977725d0de2e716784e72 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Fri, 4 Sep 2026 02:43:53 -0500
Subject: [PATCH 09/18] fix(engagement): the trigger manifest was stale, and
its check was crying wolf
Two defects, and the second is why the first survived a whole phase.
The manifest is stale. `engagement-triggers.json` embeds `moduleApiVersion`
deliberately -- "a stale manifest needs to know which API's rules produced it"
-- and Phase 7 (website#189) bumped MODULE_API_VERSION to 1.10.0 without
regenerating it. The committed file has said 1.9.0 ever since. One line, and
regenerating is the whole fix.
The check could not be believed. Both the test and the `--check` CI gate
compared bytes, and this repo is developed on Windows under core.autocrlf=true,
so git checks the committed LF blob out as CRLF and the comparison then calls an
unchanged manifest stale. That failure fires on every Windows checkout, says "a
trigger declaration changed", and is "fixed" by regenerating a file whose
content was already correct.
So the one check that exists to be believed had been failing for a reason
everyone had learned to write off as environmental -- including me, twice: the
Phase 7 PR recorded it as a pre-existing CRLF failure, and the Phase 8 PR
repeated the claim. It was neither pre-existing nor CRLF. A check that cries
wolf is a check nobody reads, and the genuine staleness underneath it went
unnoticed for exactly that reason.
Line endings are now normalised on both sides, which is the convention
routeManifest.js and routeManifest.test.js already use one file along -- that
pair had clearly hit this and been fixed; the engagement pair never was. What is
being asserted is that the committed manifest describes the same declarations,
and a line ending is not a declaration. Policing the encoding is .gitattributes'
job, not this check's.
Verify
- The check is still LIVE, proved by breaking it deliberately: with the content
changed the gate exits 1; with only the line endings changed it exits 0. That
is the whole point of the fix, so it is not taken on trust.
- `npm test` under the TAP reporter: 1950 tests, 1877 pass, 0 fail. That is
pristine `edge`'s 1950/1876 plus the one test this repairs.
A harness note, disclosed rather than buried
The default (spec) reporter intermittently reports a FILE-level failure with all
of that file's subtests passing, no assertion, and no diagnostic beyond 'test
failed'. It named a different unrelated file on each of four runs
(requireInternalKey, routeManifest, eventAuthorize, totp) and the TAP reporter
shows zero failures over the same suite. It appears to be a reporter artifact
under concurrency rather than a failing test, but it correlates with this branch
(4/4) against pristine edge (0/2) on the same machine state, which I could not
explain and am not claiming to have. Worth its own look; it does not indicate a
product defect and no assertion fails.
Co-Authored-By: Claude
---
server/engagement-triggers.json | 2 +-
server/scripts/engagementManifest.js | 16 +++++++++++++++-
server/test/engagementManifest.test.js | 10 +++++++++-
3 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index 6bc05a8..bfa13b9 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
- "moduleApiVersion": "1.9.0",
+ "moduleApiVersion": "1.10.0",
"triggers": [
{
"id": "news.post",
diff --git a/server/scripts/engagementManifest.js b/server/scripts/engagementManifest.js
index dd28be5..f22f4ba 100644
--- a/server/scripts/engagementManifest.js
+++ b/server/scripts/engagementManifest.js
@@ -108,7 +108,21 @@ function main() {
return
}
- const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
+ // **Line endings are normalised before the comparison**, exactly as
+ // `routeManifest.js` does one file along, and for a reason that is not
+ // cosmetic: this repo is developed on Windows under `core.autocrlf=true`, so
+ // git checks a committed LF blob out as CRLF and a byte comparison then calls
+ // an unchanged manifest stale. That failure is worse than useless — it fires on
+ // every Windows checkout, says "a trigger declaration changed", and is fixed by
+ // regenerating a file whose CONTENT was already correct, which teaches a
+ // developer to ignore the one check that exists to be believed.
+ //
+ // What is being asserted is that the committed manifest describes the same
+ // declarations, and a line ending is not a declaration. Policing the encoding
+ // is `.gitattributes`' job, not this check's.
+ const current = fs.existsSync(MANIFEST_PATH)
+ ? fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
+ : ''
if (current === next) {
process.stdout.write('engagement-triggers.json is current\n')
return
diff --git a/server/test/engagementManifest.test.js b/server/test/engagementManifest.test.js
index 9743971..17cd58a 100644
--- a/server/test/engagementManifest.test.js
+++ b/server/test/engagementManifest.test.js
@@ -26,8 +26,16 @@ afterEach(() => registries._reset())
const MANIFEST_PATH = path.join(__dirname, '..', 'engagement-triggers.json')
const serialize = (m) => `${JSON.stringify(m, null, 2)}\n`
+// **Normalised, like `routeManifest.test.js`'s `read()` one file along.** Under
+// `core.autocrlf=true` git checks the committed LF blob out as CRLF, so a byte
+// comparison fails on every Windows checkout while CI stays green — and it fails
+// saying "the manifest is stale", which is the one thing it is not. The claim
+// here is that the committed file describes the same declarations; a line ending
+// is not a declaration.
+const committedManifest = () => fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
+
test('the committed manifest matches the declarations in the tree', () => {
- const committed = fs.readFileSync(MANIFEST_PATH, 'utf8')
+ const committed = committedManifest()
assert.equal(
serialize(build()),
committed,
--
2.49.1
From 7d3d6d5abde3739894bef16a70ee2379a5f56ee8 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Fri, 4 Sep 2026 13:06:46 -0500
Subject: [PATCH 10/18] =?UTF-8?q?feat(events):=20the=20integrations=20?=
=?UTF-8?q?=E2=80=94=20lifecycle=20triggers,=20participants,=20results=20(?=
=?UTF-8?q?Phase=2010)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who
took part, publishes a results table, and announces a post through the legs the
news pipeline already uses. Events owns none of the delivery: a run says what
happened and an operator's rule decides who is told, so email, the in-app inbox,
push tickles, Discord and the town crier all arrive without anything in
`events/` growing a second delivery path.
**No route was added and nothing moved.** The whole surface is two more derived
fields on a run — `participants` and `resultsPublishedAt` — and a zero-line
`routes.manifest.json` diff proves it.
Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly
where `news.post` sits, and `run.failed` at `admin` on both halves. Every one
keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot
key so a deployment that has already stamped the Team and news keys still gets
them.
**The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a
rehearsal runs for real "with announcements ceilinged to `staff`" — but a
ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as
the real thing, so the moment this phase gave a run something to announce,
rehearsing a published event would have mailed every subscriber. The emit
envelope now takes an optional `ceiling` and the send-time G24 gate applies
`meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse
every rule rather than resolving to either.
`MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so
1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third
time.
Three defects the live walk found, none visible to a unit test:
1. **A channel that reported success while reaching nobody.** The seeded
`run.started` rule named `push`, because §8.5 and the plan both do. Push
delivery joins `notification_subscriptions`, only ever written for an id the
preferences screen offered push for — and it offers push only for a
registered STREAM. So the tickle went nowhere every time while
`pushChannel.deliver` answered "tickle published". `event.run.started` is now
a stream as well as a trigger; the other six are not.
2. **A trigger's `description` reaches a recipient.** It is the structural
projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing."
put those words in an administrator's own inbox item.
3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The
connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have
counted every idempotent retried collect as a fresh participant.
And one caught before it shipped: ranking with a session variable is wrong here,
because `query()` takes a pool connection per call — the variable would be set
on one connection and read on another. A window function needs no session state.
## Verification
- `npm test --prefix server` — **1981 pass, 1 fail**, and that one
(`botScore.test.js`) passes standalone at 18/18: a file-level flake under
parallel load. Run with an empty `MODULES_DIR`, as CI does.
- `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green.
- Zero-line `routes.manifest.json` / `routes.guards.json` diff.
- A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail
arrived, headed with the event's title and its start time in the shard's own
zone; the rehearsal fired the same trigger and produced zero outbox rows where
the real run produced three; `run.failed` reached the administrator's inbox
and no player's; `core.announce.post` queued a second job without touching the
news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert
were run against real MariaDB 11.
## One thing for a reviewer, out of scope and not fixed
**Every `#swagger.description` in this repo is truncated in the generated spec.**
swagger-autogen does not honour a backslash-escaped apostrophe, so a description
is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is
pre-existing and repo-wide. Only the one annotation this phase edits is fixed
here (a typographic apostrophe), because otherwise this phase's own addition to
it would be dead text. The rest wants its own change.
- [x] AI-assisted: Claude Code (Opus 5).
Docs: RunicGateway/docs#TBD.
Co-Authored-By: Claude
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
client/src/routes/admin/views/EventRun.jsx | 67 ++++
server/db/schema.sql | 80 +++++
server/engagement-triggers.json | 336 ++++++++++++++++++
server/src/config/coreEventActions.js | 168 +++++++++
server/src/config/coreStreams.js | 32 ++
server/src/config/coreTriggers.js | 226 ++++++++++++
server/src/engagement/audiences.js | 20 +-
server/src/engagement/coreRules.js | 91 ++++-
server/src/engagement/engine.js | 12 +-
server/src/engagement/templateSeeds.js | 50 +++
server/src/events/announce.js | 225 ++++++++++++
server/src/events/dispatch.js | 22 +-
server/src/events/participants.js | 162 +++++++++
.../src/model/announceJobs/announceJobs.db.js | 21 +-
.../model/announceJobs/announceJobs.model.js | 35 +-
.../model/events/eventRunControls.model.js | 11 +
server/src/model/events/eventRunLog.db.js | 8 +
.../model/events/eventRunParticipants.db.js | 119 +++++++
server/src/model/events/eventRuns.db.js | 15 +
server/src/model/events/eventRuns.model.js | 25 +-
server/src/model/posts/posts.db.js | 14 +
.../src/router/v1/admin/events.controller.js | 9 +
server/src/router/v1/admin/events.router.js | 4 +-
server/src/utils/engagementEmit.js | 31 +-
server/src/utils/eventRunner.js | 67 +++-
server/swagger/swagger-output.json | 15 +-
server/test/engagementEngine.test.js | 42 +++
server/test/engagementTriggers.test.js | 42 ++-
server/test/eventActionRegistry.test.js | 23 +-
server/test/eventAnnounce.test.js | 308 ++++++++++++++++
server/test/eventIntegrations.test.js | 311 ++++++++++++++++
server/test/eventParticipants.test.js | 193 ++++++++++
server/test/eventRunControls.test.js | 45 ++-
server/test/eventRunner.test.js | 129 ++++++-
server/test/eventsAdmin.test.js | 30 +-
server/test/moduleRegistries.test.js | 11 +
36 files changed, 2960 insertions(+), 39 deletions(-)
create mode 100644 server/src/events/announce.js
create mode 100644 server/src/events/participants.js
create mode 100644 server/src/model/events/eventRunParticipants.db.js
create mode 100644 server/test/eventAnnounce.test.js
create mode 100644 server/test/eventIntegrations.test.js
create mode 100644 server/test/eventParticipants.test.js
diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx
index e3ff02e..78398f6 100644
--- a/client/src/routes/admin/views/EventRun.jsx
+++ b/client/src/routes/admin/views/EventRun.jsx
@@ -93,6 +93,12 @@ const STEP_COLOR = {
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
+// How many participants the console renders before it stops and counts the rest.
+// A run's participants are people and a busy event has hundreds; this panel is a
+// check that the collection worked and that the ranking looks right, not the
+// results page — that is Phase 14's, and it is public.
+const PARTICIPANTS_SHOWN = 50
+
/**
* Seconds as an operator reads them — the same vocabulary the spec authors a
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
@@ -183,6 +189,9 @@ export default function EventRun() {
// What this run created or borrowed, and what became of each (Phase 8).
const [resources, setResources] = useState([])
const [unresolved, setUnresolved] = useState(0)
+ // Who took part, best first (Phase 10). Present whether or not the results
+ // have been published; `run.resultsPublishedAt` is what says which.
+ const [participants, setParticipants] = useState([])
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -205,6 +214,7 @@ export default function EventRun() {
setBudget(detail.budget || [])
setResources(detail.resources || [])
setUnresolved(detail.unresolvedResources || 0)
+ setParticipants(detail.participants || [])
setLines(log.log || [])
}, [runId])
@@ -522,6 +532,63 @@ export default function EventRun() {
)}
+ {/* ── Who took part (Phase 10) ──
+ Shown whenever a module has reported anybody, published or not — and the
+ difference between the two is the whole point of the line under the
+ heading. A run whose participants are collected and unranked is a real
+ state, not an error: an author has not placed a `core.results.publish`
+ step, or has not run it yet. Saying "not published yet" is what stops
+ somebody reading this table as the final standings. */}
+ {participants.length > 0 && (
+
+
+ Who took part
+
+
+ {run.resultsPublishedAt ? (
+ <>Results published {clock(run.resultsPublishedAt)}. Ranked best first.>
+ ) : (
+ <>
+ {participants.length} recorded, and the results have not been published — nothing
+ outside this page shows them, and nobody has a rank yet. Publishing is a{' '}
+ core.results.publish step in the event
+ itself.
+ >
+ )}
+
+ {/* A participant with no `userId` is not a defect: it is
+ somebody who turned up without a linked website account,
+ and the module is the only thing that could have known
+ otherwise. Saying so beats a blank cell. */}
+
+ and {participants.length - PARTICIPANTS_SHOWN} more.
+
+ )}
+
+ )}
+
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 76744bb..805f4bb 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -2597,3 +2597,83 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
-- start is watching, and that human is the review the gate exists to require.
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
+
+-- ── Integrations: participants, results and the run's announcements
+-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
+
+-- Who took part, and how well. The eleventh and last of §D's core tables.
+--
+-- **Core writes this table and never sources it.** A `member_key` is
+-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of
+-- Britain" onto a user row and must not try, because the mapping is one game's
+-- (`shard_links`, for module-uo) and would be compiled into core the moment it
+-- guessed. A module that knows both halves supplies both — `memberKey` always,
+-- `userId` when its own link table has one — and core stores what it is told.
+--
+-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what
+-- happened at an event has to survive the deletion of an account that attended
+-- it, or the results of last year's invasion silently rewrite themselves.
+--
+-- **`rank` is NULL until results are published** and is computed then, by
+-- `core.results.publish`, over `score DESC`. It is a stored column rather than a
+-- window function in the read because a published result is a fact about a
+-- moment: a participant added afterwards (a late correction, a module's second
+-- collect step) must not silently renumber a table people have already read.
+CREATE TABLE IF NOT EXISTS event_run_participants (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ run_id BIGINT NOT NULL,
+ -- Module-opaque and NOT NULL: it is the identity the module knows, and the
+ -- half of the unique key that makes a repeated collect idempotent. A run whose
+ -- module cannot name its participants has no rows here at all.
+ member_key VARCHAR(190) NOT NULL,
+ user_id INT NULL,
+ -- Signed, because a game may score downward as readily as upward, and DECIMAL
+ -- rather than a float so two equal scores compare equal and a rank is stable.
+ score DECIMAL(18,4) NOT NULL DEFAULT 0,
+ rank_at INT NULL,
+ joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ -- Module-opaque. Whatever the module wants results to be able to display
+ -- beside a name -- a class, a city, a kill count -- with no core vocabulary in
+ -- it and nothing core ever reads.
+ meta JSON NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
+ CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
+ -- One row per participant per run. What makes a module reporting the same set
+ -- twice -- a retried collect step, a second attempt after a timeout -- an
+ -- upsert rather than a duplicated leaderboard.
+ UNIQUE KEY uq_evpart_member (run_id, member_key),
+ -- The results table: one run, best first.
+ INDEX idx_evpart_score (run_id, score),
+ -- Profile history (`GET /player/events/history`, Phase 14), newest first.
+ INDEX idx_evpart_user (user_id, joined_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- When the results table was published, and by which run of the publish action.
+--
+-- A stamp rather than a status: a run either has published results or it has
+-- not, and the two questions a surface asks -- "may I show this table" and "when
+-- was it settled" -- are the same column. `core.results.publish` is idempotent
+-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an
+-- ordinary retried step.
+ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL;
+
+-- The run an announce job belongs to, when it belongs to one.
+--
+-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are
+-- not an event's, and nothing about how they are enqueued, retried or rolled up
+-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job
+-- for a post that has already been announced -- the common case, since the post
+-- an event announces is very often the news post that announced it -- without
+-- either colliding with the first or overwriting `posts.announce_job_id`, which
+-- is the back-pointer the post admin panel's retry button reads.
+--
+-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce
+-- job that went out is a delivery record and must outlive whatever asked for it,
+-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a
+-- constraint here would be the one statement in this file that cannot replay.
+-- The column is read only to answer "which run announced this", and a run id
+-- that no longer resolves answers that honestly.
+ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
+ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index bfa13b9..4c0ab06 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -2,6 +2,342 @@
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.10.0",
"triggers": [
+ {
+ "id": "event.phase.changed",
+ "owner": "core",
+ "label": "Event — a new phase",
+ "description": "An event that is under way has moved on to its next stage.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "subscribers",
+ "ceiling": "authenticated",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ },
+ {
+ "name": "phase",
+ "type": "string",
+ "required": true,
+ "example": "assault",
+ "description": "The phase key just entered, as authored in the spec."
+ },
+ {
+ "name": "phaseLabel",
+ "type": "string",
+ "required": false,
+ "example": "The assault",
+ "description": "The phase label, when the spec gave it one. Falls back to the key."
+ },
+ {
+ "name": "phaseIndex",
+ "type": "int",
+ "required": true,
+ "example": 2,
+ "description": "Which phase this is, counting from 1."
+ },
+ {
+ "name": "phaseCount",
+ "type": "int",
+ "required": true,
+ "example": 4,
+ "description": "How many phases the pinned version has in total."
+ }
+ ]
+ },
+ {
+ "id": "event.run.cancelled",
+ "owner": "core",
+ "label": "Event — cancelled",
+ "description": "A scheduled event was cancelled by a member of staff.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "subscribers",
+ "ceiling": "authenticated",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ },
+ {
+ "name": "reason",
+ "type": "string",
+ "required": false,
+ "example": "The shard is down for an emergency patch.",
+ "description": "What the staff member gave as the reason, when they gave one."
+ }
+ ]
+ },
+ {
+ "id": "event.run.completed",
+ "owner": "core",
+ "label": "Event — finished",
+ "description": "An event has finished.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "subscribers",
+ "ceiling": "authenticated",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ },
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event summary, as authored."
+ },
+ {
+ "name": "participantCount",
+ "type": "int",
+ "required": true,
+ "example": 47,
+ "description": "How many participants the run recorded. Zero when nothing collected any."
+ },
+ {
+ "name": "durationMinutes",
+ "type": "int",
+ "required": true,
+ "example": 95,
+ "description": "How long the run took, start to end, in whole minutes."
+ }
+ ]
+ },
+ {
+ "id": "event.run.ending",
+ "owner": "core",
+ "label": "Event — winding down",
+ "description": "An event is drawing to a close.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "subscribers",
+ "ceiling": "authenticated",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ }
+ ]
+ },
+ {
+ "id": "event.run.failed",
+ "owner": "core",
+ "label": "Event — run failed",
+ "description": "An event stopped before it finished.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "admin",
+ "ceiling": "admin",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ },
+ {
+ "name": "phase",
+ "type": "string",
+ "required": false,
+ "example": "assault",
+ "description": "The phase it failed in, when it had entered one."
+ },
+ {
+ "name": "error",
+ "type": "string",
+ "required": false,
+ "example": "sidecar responded 503",
+ "description": "The run’s last error, verbatim from the run row."
+ },
+ {
+ "name": "runUrl",
+ "type": "url",
+ "required": true,
+ "example": "/admin/events/runs/3692",
+ "description": "Site-relative path to the run console."
+ }
+ ]
+ },
+ {
+ "id": "event.run.scheduled",
+ "owner": "core",
+ "label": "Event — scheduled",
+ "description": "A new event has been added to the calendar.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "subscribers",
+ "ceiling": "authenticated",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ },
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event summary, as authored."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "startsAt",
+ "type": "datetime",
+ "required": true,
+ "example": "2026-09-12T20:00:00.000Z",
+ "description": "When the occurrence is due to start, UTC."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The shard-local zone the schedule was authored in."
+ },
+ {
+ "name": "startsAtLabel",
+ "type": "string",
+ "required": false,
+ "example": "Saturday 12 September at 8:00 pm (America/New_York)",
+ "description": "The start time written out in the shard-local zone, for a mail to read."
+ }
+ ]
+ },
+ {
+ "id": "event.run.started",
+ "owner": "core",
+ "label": "Event — starting now",
+ "description": "A scheduled event has begun.",
+ "kind": "event",
+ "subjectKey": "runId",
+ "audience": "subscribers",
+ "ceiling": "authenticated",
+ "version": 1,
+ "variables": [
+ {
+ "name": "runId",
+ "type": "string",
+ "required": true,
+ "example": "3692",
+ "description": "The run this is about. Also the cooldown subject."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": true,
+ "example": "The Yew Invasion",
+ "description": "The event title."
+ },
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event summary, as authored."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "startsAt",
+ "type": "datetime",
+ "required": true,
+ "example": "2026-09-12T20:00:00.000Z",
+ "description": "When it actually started, UTC."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The shard-local zone the schedule was authored in."
+ },
+ {
+ "name": "startsAtLabel",
+ "type": "string",
+ "required": false,
+ "example": "Saturday 12 September at 8:00 pm (America/New_York)",
+ "description": "The start time written out in the shard-local zone, for a mail to read."
+ }
+ ]
+ },
{
"id": "news.post",
"owner": "core",
diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js
index e124ac7..b972530 100644
--- a/server/src/config/coreEventActions.js
+++ b/server/src/config/coreEventActions.js
@@ -22,6 +22,16 @@
// verb per module would be that bound re-implemented once per module, advisory
// everywhere, and wrong in the first one that forgot it.
//
+// **Phase 10 added the last two, and they are the integrations** (EVENTS.md
+// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a
+// post an editor already wrote and queues it through `announce_jobs`, so the
+// town crier and Discord arrive as already-registered legs with their retry and
+// their classification rather than as a second delivery pipeline. And
+// `core.results.publish` is what makes §F's "publish results" literal: it ranks
+// the run's participants and stamps the table published. Both name a game noun
+// nowhere, which is why they are core's; six actions is now the whole of what an
+// event can do on a deployment with no game module installed at all.
+//
// **Phase 2 gave all three real bodies**, and between them they exercise every
// shape §F's envelope can take: `core.announce` does work and finishes,
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
@@ -413,6 +423,142 @@ const ACTIONS = [
return { ok: true }
},
},
+
+ {
+ id: 'core.announce.post',
+ label: 'Announce a post',
+ description:
+ 'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.',
+
+ // Nothing in the world changes and nothing is created; a message goes out.
+ // Same class as `core.announce` and for the same reason.
+ risk: 'notify',
+ // The job is queued, the legs deliver, and none of it can be unsent. A
+ // `ledger` here would put a row in the cleanup ledger that teardown could
+ // never resolve.
+ reversible: 'none',
+ version: 1,
+
+ // **`core.announce` sends a line; this sends an ARTICLE**, and that is the
+ // whole difference between them (EVENTS.md §J, "News"). Events does not
+ // write posts — `ctx.posts` is read-only to modules and the CMS is core's —
+ // so an event that wants prose, an image and a permanent page links a post
+ // an editor already wrote. What this action adds over `core.announce` is
+ // therefore not a second transport but a second SHAPE: every leg's
+ // `dispatch()` takes a post, and this is the one that hands it a real one.
+ params: [
+ {
+ name: 'postId',
+ type: 'int',
+ required: true,
+ example: 412,
+ source: 'core.options.posts',
+ description: 'The published post to announce. Any category.',
+ },
+ ],
+
+ /**
+ * Queue the post on every registered leg, as this run's announcement.
+ *
+ * **The refusals are all `retry: false`**, and each is a thing a human has to
+ * fix: a post id that names nothing, or a draft. Neither will have changed
+ * sixty seconds later, and retrying would spend two more attempts before
+ * saying the same thing.
+ *
+ * **What it does NOT wait for is delivery.** `enqueueForRun` writes the job
+ * and the legs and returns; `announceWorker` drains them on its own tick with
+ * its own backoff. So this step is `done` when the announcement is queued,
+ * not when Discord has it — which is honest, because a leg that fails after
+ * six attempts over two hours is not something a step could usefully have
+ * stayed open for, and the post admin panel is where that failure is already
+ * surfaced.
+ */
+ async perform({ runId, params, verify }) {
+ /* eslint-disable global-require */
+ const posts = require('../model/posts/posts.model')
+ const announceJobs = require('../model/announceJobs/announceJobs.model')
+ /* eslint-enable global-require */
+
+ const postId = Number(params.postId)
+ if (!Number.isInteger(postId) || postId < 1) {
+ return { ok: false, retry: false, error: `"${params.postId}" is not a post id` }
+ }
+
+ const post = await posts.getById(postId)
+ if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` }
+ if (!post.published) {
+ // A draft has no public page for a town-crier line to point at, and
+ // announcing one would publish its title to a shard before an editor
+ // meant to. Refused rather than published on the author's behalf:
+ // publishing is the CMS's decision and this action is not it.
+ return { ok: false, retry: false, error: `"${post.title}" is not published` }
+ }
+
+ // The dry run has now checked everything worth checking — the post exists
+ // and is published — and queues nothing. Checked BEFORE the legs are read,
+ // because a deployment with no leg registered is a real state and a verify
+ // that reported it as a failure would refuse a plan that is fine.
+ if (verify) return { ok: true }
+
+ await announceJobs.enqueueForRun(postId, runId)
+ return { ok: true }
+ },
+ },
+
+ {
+ id: 'core.results.publish',
+ label: 'Publish the results',
+ description:
+ 'Rank this run\'s participants by score and publish the results table.',
+
+ // Nothing in the game world changes and nobody is messaged: a table core
+ // already holds becomes readable. `inspect` is the weakest class the closed
+ // set has and it is the honest one — which also means this action is
+ // default-ON like `core.wait`, and an author can place it without an admin
+ // first visiting the switchboard.
+ risk: 'inspect',
+ // **`none`, and it is worth saying why a publication is not reversible.**
+ // Nothing is created that core would have to come back for; un-publishing is
+ // an admin decision about a table, not a teardown obligation, and a `ledger`
+ // row here would make every completed event carry an outstanding resource
+ // for ever.
+ reversible: 'none',
+ version: 1,
+
+ // No params. What is published is this run's participants, which is the only
+ // set there is — a param naming which run would be a way to publish someone
+ // else's results from inside your own event.
+ params: [],
+
+ /**
+ * Rank, stamp, and say how many.
+ *
+ * **Idempotent by construction**, which is what makes it safe as an ordinary
+ * retried step: ranking is a total order over `(score, joined_at, id)`, so
+ * running it twice over an unchanged table writes the same numbers, and the
+ * stamp simply moves. A late participant added by a second collect step and
+ * a re-publish afterwards renumbers deliberately — that is the operator
+ * asking for exactly that.
+ *
+ * **A run with no participants publishes an empty table rather than
+ * failing.** "Nobody was recorded" is a true and renderable result, and it is
+ * the state of every run until a module can source attendance at all (Phase
+ * 12). Failing here would make an event whose module reports nothing look
+ * broken on the console for a reason that has nothing to do with the event.
+ */
+ async perform({ runId, verify }) {
+ /* eslint-disable global-require */
+ const participantsDb = require('../model/events/eventRunParticipants.db')
+ const runsDb = require('../model/events/eventRuns.db')
+ /* eslint-enable global-require */
+
+ if (verify) return { ok: true }
+
+ await participantsDb.rankRun(runId)
+ await runsDb.markResultsPublished(runId)
+ return { ok: true }
+ },
+ },
]
// ── Core's own param option sources (§F, Phase 7) ──────────────────
@@ -446,6 +592,28 @@ const OPTION_SOURCES = [
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
},
},
+ {
+ id: 'core.options.posts',
+ label: 'Published posts',
+ description: 'Every published post an event may announce, newest first.',
+ /**
+ * **The one option source in core that reaches a table**, and the reason it
+ * is allowed to is the rule §F draws about WHEN: a source resolves on its own
+ * request (`GET /admin/events/catalog/options/:sourceId`), which is a live
+ * request on a booted server, not at `register()` time under a dead pool.
+ *
+ * Grouped by category so the dropdown separates news from the newsletter
+ * rather than presenting one long list in which the two are indistinguishable
+ * — a `group` is what the form renders as an optgroup, and it costs a column
+ * that is already selected.
+ */
+ async resolve() {
+ // eslint-disable-next-line global-require
+ const postsDb = require('../model/posts/posts.db')
+ const rows = await postsDb.listPublishedForOptions(200)
+ return rows.map((p) => ({ value: p.id, label: p.title, group: p.category }))
+ },
+ },
]
module.exports = { ACTIONS, OPTION_SOURCES }
diff --git a/server/src/config/coreStreams.js b/server/src/config/coreStreams.js
index 20e3bc1..e9fa49d 100644
--- a/server/src/config/coreStreams.js
+++ b/server/src/config/coreStreams.js
@@ -75,6 +75,38 @@ const STREAMS = [
personal: false,
requiresLinkedAccount: false,
},
+
+ // ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
+ //
+ // **One of the seven `event.` triggers is also a stream, and that is a
+ // decision rather than an oversight** (org lead, 2026-09-04). A stream is a
+ // PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only
+ // for ids registered here, `publishToUsers` joins `notification_subscriptions`,
+ // and that table is only ever written for a channel a user could switch on. So
+ // a trigger that is not also a stream can be mailed and put in the inbox, and
+ // its push is dead — a tickle published to nobody, which the send log
+ // nonetheless records as sent. Found on the live rig; the seeded rule named
+ // `push` before this line existed.
+ //
+ // **`run.started` alone, because push is the channel that says "now".** It is
+ // the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's
+ // *"come back for X"* — and the other six are things a player reads when they
+ // next look. Six more toggles would put a wall of switches on the preferences
+ // screen for one feature, and `event.phase.changed` is the one most likely to
+ // buzz a phone four times in an evening.
+ //
+ // Same id as the trigger, which is §7.2's one namespace and the same-owner
+ // upgrade `news.post` already is: one id, one owner, two facets.
+ {
+ id: 'event.run.started',
+ label: 'Events — starting now',
+ description: 'A scheduled event is beginning.',
+ // Not owner-keyed: this is a public event happening in public, not a fact
+ // about one account's own property. Same as `news.post`.
+ personal: false,
+ // A player with no linked game account can still want to know an event is on.
+ requiresLinkedAccount: false,
+ },
]
module.exports = { STREAMS }
diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js
index d9ff10a..eba59d7 100644
--- a/server/src/config/coreTriggers.js
+++ b/server/src/config/coreTriggers.js
@@ -149,6 +149,232 @@ const TRIGGERS = [
description: 'Site-relative path to the announcement.' },
],
},
+
+ // ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
+ //
+ // **Seven triggers, one per moment a run passes through that somebody outside
+ // the run console might want to hear about — and Events owns none of the
+ // delivery.** A run emits; an operator's rule decides who is told, on what,
+ // and how often. That is the whole of §J's "clean fit" row, and it is why
+ // there is no announcement machinery anywhere in `utils/eventRunner.js`
+ // beyond a call to `emit`.
+ //
+ // **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org
+ // lead 2026-09-04). `run.failed` is an operational fact — a step ran out of
+ // attempts, the world may be half-changed — and a rule that mailed it to
+ // every subscriber would publish the deployment's incidents. The other six
+ // describe a public event happening in public, so they sit exactly where
+ // `news.post` sits: ceiling `authenticated`, default audience `subscribers`,
+ // which is "people who asked to be told" rather than the whole user table.
+ //
+ // **Every `description` here is read by two audiences**, and the second one is
+ // easy to forget: the rule editor's catalog, and — through
+ // `projection.project`'s `intro` fallback — every recipient of an unauthored
+ // render through `notify.event` or `inapp.event`. So each is prose a player
+ // can read rather than a note to the operator. The live rig caught the
+ // original `run.failed` line, which ended "Staff-facing." and put those words
+ // in an administrator's own inbox item. Who a trigger is for is said by its
+ // CEILING, which is the only place that can enforce it anyway.
+ //
+ // **`subjectKey: 'runId'` on every one of them**, and it is the one place
+ // these differ from `news.post`. A cooldown keyed on the user would make
+ // `phase.changed` mean "at most one phase of at most one event an hour",
+ // silently swallowing the second wave of an invasion because the first wave's
+ // mail went out forty minutes ago. Keyed on the run it means "at most one
+ // line an hour ABOUT THIS RUN", which is the useful sentence — and across
+ // runs of the same definition the ids differ, so a weekly event is not
+ // throttled by last week's.
+ //
+ // **None of the six public ones declares a `url` variable, deliberately.**
+ // There is no public event page until Phase 14 — `App.jsx` mounts nothing
+ // under `/site/events` — and `news.post` has already paid for this mistake
+ // once: its `postUrl` example named `/news/`, a path that does not
+ // exist, and the template editor previewed a link that was dead in every mail
+ // it sent. A variable added in Phase 14 alongside the page it points at is a
+ // version bump; a variable shipped now is a 404 in an operator's first
+ // announcement. `run.failed` is the exception because its destination exists
+ // today: `/admin/events/runs/:runId` is a real route and an admin can read it.
+ {
+ id: 'event.run.scheduled',
+ label: 'Event — scheduled',
+ description: 'A new event has been added to the calendar.',
+ kind: 'event',
+ subjectKey: 'runId',
+ audience: 'subscribers',
+ ceiling: 'authenticated',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
+ description: 'The event summary, as authored.' },
+ { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
+ description: 'The arc this event belongs to, when it belongs to one.' },
+ { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
+ description: 'When the occurrence is due to start, UTC.' },
+ { name: 'timezone', type: 'string', required: false, example: 'America/New_York',
+ description: 'The shard-local zone the schedule was authored in.' },
+ // **A presentational fragment, and §4.6.1 convention 1 is what sanctions
+ // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
+ // string — correct as data and unreadable in a mail, and a template has no
+ // logic with which to format it. So the formatting happens at the emitter,
+ // in the shard-local zone, and arrives as a variable whose `example` shows
+ // exactly what it produces. Same trade `forWhom` makes in the auth bodies.
+ { name: 'startsAtLabel', type: 'string', required: false,
+ example: 'Saturday 12 September at 8:00 pm (America/New_York)',
+ description: 'The start time written out in the shard-local zone, for a mail to read.' },
+ ],
+ },
+ {
+ id: 'event.run.started',
+ label: 'Event — starting now',
+ description: 'A scheduled event has begun.',
+ kind: 'event',
+ subjectKey: 'runId',
+ audience: 'subscribers',
+ ceiling: 'authenticated',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
+ description: 'The event summary, as authored.' },
+ { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
+ description: 'The arc this event belongs to, when it belongs to one.' },
+ { name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
+ description: 'When it actually started, UTC.' },
+ { name: 'timezone', type: 'string', required: false, example: 'America/New_York',
+ description: 'The shard-local zone the schedule was authored in.' },
+ // **A presentational fragment, and §4.6.1 convention 1 is what sanctions
+ // one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
+ // string — correct as data and unreadable in a mail, and a template has no
+ // logic with which to format it. So the formatting happens at the emitter,
+ // in the shard-local zone, and arrives as a variable whose `example` shows
+ // exactly what it produces. Same trade `forWhom` makes in the auth bodies.
+ { name: 'startsAtLabel', type: 'string', required: false,
+ example: 'Saturday 12 September at 8:00 pm (America/New_York)',
+ description: 'The start time written out in the shard-local zone, for a mail to read.' },
+ ],
+ },
+ {
+ id: 'event.phase.changed',
+ label: 'Event — a new phase',
+ description: 'An event that is under way has moved on to its next stage.',
+ kind: 'event',
+ subjectKey: 'runId',
+ audience: 'subscribers',
+ ceiling: 'authenticated',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ { name: 'phase', type: 'string', required: true, example: 'assault',
+ description: 'The phase key just entered, as authored in the spec.' },
+ { name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
+ description: 'The phase label, when the spec gave it one. Falls back to the key.' },
+ { name: 'phaseIndex', type: 'int', required: true, example: 2,
+ description: 'Which phase this is, counting from 1.' },
+ { name: 'phaseCount', type: 'int', required: true, example: 4,
+ description: 'How many phases the pinned version has in total.' },
+ ],
+ },
+ {
+ id: 'event.run.ending',
+ label: 'Event — winding down',
+ description: 'An event is drawing to a close.',
+ kind: 'event',
+ subjectKey: 'runId',
+ audience: 'subscribers',
+ ceiling: 'authenticated',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ ],
+ },
+ {
+ id: 'event.run.completed',
+ label: 'Event — finished',
+ description: 'An event has finished.',
+ kind: 'event',
+ subjectKey: 'runId',
+ audience: 'subscribers',
+ ceiling: 'authenticated',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
+ description: 'The event summary, as authored.' },
+ // Counted from `event_run_participants` at emit. Zero on a run whose
+ // module reported nobody, which is every run until a module collects —
+ // a template that says "47 took part" needs a number that is never
+ // missing, and "0" is the honest one.
+ { name: 'participantCount', type: 'int', required: true, example: 47,
+ description: 'How many participants the run recorded. Zero when nothing collected any.' },
+ { name: 'durationMinutes', type: 'int', required: true, example: 95,
+ description: 'How long the run took, start to end, in whole minutes.' },
+ ],
+ },
+ {
+ id: 'event.run.cancelled',
+ label: 'Event — cancelled',
+ description: 'A scheduled event was cancelled by a member of staff.',
+ kind: 'event',
+ subjectKey: 'runId',
+ audience: 'subscribers',
+ ceiling: 'authenticated',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ // **The operator's reason, and not the run's `last_error`.** `cancel`
+ // takes a `{ reason }` a human typed for other humans; a diagnostic
+ // string is for the run console and would read as gibberish in a mail.
+ { name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
+ description: 'What the staff member gave as the reason, when they gave one.' },
+ ],
+ },
+ {
+ id: 'event.run.failed',
+ label: 'Event — run failed',
+ description: 'An event stopped before it finished.',
+ kind: 'event',
+ subjectKey: 'runId',
+ // **`admin`, and both halves of that.** The ceiling is the security
+ // boundary (§J, G24): no rule may ever widen this past admins, because a
+ // failure names the deployment's own broken machinery. The default audience
+ // matches, so a rule created from this trigger starts where it must end.
+ audience: 'admin',
+ ceiling: 'admin',
+ version: 1,
+ variables: [
+ { name: 'runId', type: 'string', required: true, example: '3692',
+ description: 'The run this is about. Also the cooldown subject.' },
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
+ description: 'The event title.' },
+ { name: 'phase', type: 'string', required: false, example: 'assault',
+ description: 'The phase it failed in, when it had entered one.' },
+ { name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
+ description: 'The run’s last error, verbatim from the run row.' },
+ // The one url variable in this file's Phase 10 block, and the reason is
+ // that this route exists TODAY. See the note above the six.
+ { name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
+ description: 'Site-relative path to the run console.' },
+ ],
+ },
]
module.exports = { TRIGGERS }
diff --git a/server/src/engagement/audiences.js b/server/src/engagement/audiences.js
index 29a9285..ff10a76 100644
--- a/server/src/engagement/audiences.js
+++ b/server/src/engagement/audiences.js
@@ -156,11 +156,27 @@ async function resolveForRule(rule, event) {
* same id more tightly. That is precisely the case where a stale rule would
* otherwise mail a population the current declaration forbids, which is what
* makes this the security boundary rather than a duplicate check.
+ *
+ * **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may
+ * carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the
+ * effective bound is the MEET of the two, so a firing can only ever narrow what
+ * the declaration allows. Two incomparable ceilings meet to null and the gate
+ * refuses: `owner` and `staff` have no common descendant, and picking one would
+ * be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value
+ * cannot get here — `emit` validates it against the same lattice — but the null
+ * is handled anyway, because this is the boundary and a boundary that trusts its
+ * caller is not one.
+ *
+ * @param {string} triggerId
+ * @param {string} ceiling the audience the rule resolved to
+ * @param {string|null} [emitted] a narrowing ceiling this firing carries
*/
-function permitted(triggerId, ceiling) {
+function permitted(triggerId, ceiling, emitted = null) {
const declaration = registries.eventTrigger(triggerId)
if (!declaration) return false
- return ceilings.permits(declaration.ceiling, ceiling)
+ const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
+ if (!bound) return false
+ return ceilings.permits(bound, ceiling)
}
module.exports = { resolveForRule, permitted, defaultOnChannels }
diff --git a/server/src/engagement/coreRules.js b/server/src/engagement/coreRules.js
index e819f3d..f573469 100644
--- a/server/src/engagement/coreRules.js
+++ b/server/src/engagement/coreRules.js
@@ -1,4 +1,4 @@
-// ── The five rules core ships, all of them OFF ─────────────────────────────
+// ── The seven rules core ships, all of them OFF ────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
@@ -34,6 +34,11 @@
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
// existing group is a rule that only fresh installs will ever see.
+//
+// **EVENTS.md Phase 10 added two more, and a third key**, for the event
+// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third
+// application — a deployment that has already stamped the news key must still
+// see these.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
@@ -46,6 +51,9 @@ const SEEDED_KEY = 'engagement_team_rules_seeded'
// Phase 11's, and separate for the reason above. Same shape, same semantics.
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
+// EVENTS.md Phase 10's, third group, third key.
+const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded'
+
const RULES = [
{
trigger_id: 'team.forum.post',
@@ -145,6 +153,73 @@ const NEWS_RULES = [
},
]
+// Phase 10's two, in their own list under their own one-shot key — the rule
+// Phase 11 established, applied for the second time. Appending to `NEWS_RULES`
+// would seed these on fresh installs only and on exactly the upgrades that want
+// them, never.
+//
+// **Two rules for seven triggers, and that is the whole decision** (org lead,
+// 2026-09-04). Every one of the seven is declared, so an operator can write a
+// rule against any of them from the rules screen; what is SEEDED is the pair
+// somebody would otherwise have to build from scratch on the first day — the
+// player-facing "it is starting" and the staff-facing "it broke". Seeding all
+// seven would grow Admin → Engagement → Rules by seven disabled rows nobody
+// asked for, and `event.phase.changed` is the one most likely to be switched on
+// by accident and then mail a player four times in one evening.
+const EVENT_RULES = [
+ {
+ trigger_id: 'event.run.started',
+ name: 'Events — starting now',
+ // `subscribers`, the trigger's own default: people who opted into this id on
+ // at least one channel. Not `authenticated`, even though the ceiling permits
+ // it — an event is worth telling people who asked to be told about events,
+ // and mailing the whole user table every Saturday night is how a feature
+ // earns a spam complaint. An operator who wants the whole site can widen it;
+ // the ceiling is what stops them widening it past that.
+ audience: 'subscribers',
+ // All three, like the news rule and for the same reason: push is the channel
+ // that gets somebody to log in *now*, which is the entire point of a
+ // "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is
+ // the surface a content-free tickle deep-links into.
+ channels: ['email', 'inapp', 'push'],
+ // The one bespoke body this phase seeds; see `templateSeeds.js` for why it
+ // is one and not seven. `inapp.event` is the in-app renderer's generic, and
+ // push carries no content by construction and needs no template.
+ template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' },
+ // An hour, per user PER RUN — `event.run.started` declares `subjectKey:
+ // 'runId'`, so the cooldown subject is the run and not the recipient. It is
+ // near-redundant on a trigger that fires once per run, which is the point:
+ // it costs nothing and it is the guard if a run is ever restarted.
+ cooldown_seconds: 3600,
+ max_sends_per_hour: 1000,
+ },
+ {
+ trigger_id: 'event.run.failed',
+ name: 'Events — a run failed',
+ // `admin`, which is both the trigger's default and its ceiling. A failed run
+ // names the deployment's own broken machinery — a sidecar that did not
+ // answer, a step that ran out of attempts — and there is no widening of this
+ // that is not a disclosure.
+ audience: 'admin',
+ // No push. An admin's phone buzzing at four in the morning for a step that
+ // will still be failed at breakfast is a notification people switch off
+ // wholesale, and switching it off wholesale is how the one that mattered is
+ // missed. Mail and the inbox both wait.
+ channels: ['email', 'inapp'],
+ // The generic body plus the structural projection: `event.run.failed`
+ // declares its own `title` and a `runUrl`, so an unauthored mail is already
+ // headed with the event's name and buttoned through to the run console —
+ // §4.6.1 property 1, working exactly as it promises.
+ template_keys: { email: 'notify.event', inapp: 'inapp.event' },
+ // **No cooldown, and this is the one rule in the file that must not have
+ // one.** The subject is the run, so a cooldown would only ever suppress a
+ // second failure of the SAME run — which is precisely the run an
+ // administrator most needs the second line about.
+ cooldown_seconds: 0,
+ max_sends_per_hour: 200,
+ },
+]
+
/**
* Seed one group of rules, once, under its own guard key.
*
@@ -185,7 +260,7 @@ async function seedGroup(key, rules, note) {
})
summary.inserted += 1
} catch (err) {
- log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
+ log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message })
}
}
// Stamped even on a partial run — the claim above is the stamp. Re-running
@@ -209,6 +284,10 @@ const seedTeamRules = () =>
const seedNewsRule = () =>
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
+/** The two event-lifecycle rules (EVENTS.md Phase 10). */
+const seedEventRules = () =>
+ seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one')
+
/**
* Both groups, which is what the boot path calls.
*
@@ -219,9 +298,10 @@ const seedNewsRule = () =>
async function seedCoreRules() {
const team = await seedTeamRules()
const news = await seedNewsRule()
+ const events = await seedEventRules()
return {
- inserted: team.inserted + news.inserted,
- skipped: team.skipped + news.skipped,
+ inserted: team.inserted + news.inserted + events.inserted,
+ skipped: team.skipped + news.skipped + events.skipped,
}
}
@@ -229,8 +309,11 @@ module.exports = {
seedCoreRules,
seedTeamRules,
seedNewsRule,
+ seedEventRules,
RULES,
NEWS_RULES,
+ EVENT_RULES,
SEEDED_KEY,
NEWS_SEEDED_KEY,
+ EVENT_SEEDED_KEY,
}
diff --git a/server/src/engagement/engine.js b/server/src/engagement/engine.js
index e1447c1..52d3b88 100644
--- a/server/src/engagement/engine.js
+++ b/server/src/engagement/engine.js
@@ -150,13 +150,21 @@ async function applyRule(rule, event, now) {
// G24, re-run at send time. A rule saved when its trigger permitted a wider
// audience must not keep reaching it after a module upgrade narrowed the
- // declaration - and that is the only way this can fail, since the save path
+ // declaration - and that was the only way this could fail, since the save path
// ran the same check.
- if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
+ //
+ // **Phase 10 gave it a second way, and it is the one that fires in practice:**
+ // the event may carry a narrowing ceiling of its own. A rehearsal emits
+ // `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote
+ // for the real thing is then refused here rather than mailing subscribers about
+ // an event that is not happening. Nothing about the rule changed; the occasion
+ // did. See `audiences.permitted`.
+ if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) {
log.warn('rule audience exceeds its trigger ceiling - refusing', {
rule: rule.id,
trigger: event.triggerId,
audience: resolved.ceiling,
+ emitted: event.ceiling || null,
})
summary.skipped = 'ceiling'
return summary
diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js
index d77e0f0..2088c40 100644
--- a/server/src/engagement/templateSeeds.js
+++ b/server/src/engagement/templateSeeds.js
@@ -310,6 +310,56 @@ const SEEDS = [
button('cta', 'Open', '{{actionUrl}}'),
],
},
+ // ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
+ //
+ // **One body, not seven.** Six of the seven `event.` triggers render through
+ // `notify.event` and the structural projection with no authoring at all
+ // (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
+ // is already headed with the event's name — and seeding a bespoke body per
+ // trigger would be seven templates an operator has to maintain to change one
+ // sentence.
+ //
+ // `event.run.started` gets one because it is the flagship: the mail that
+ // answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
+ // an operator will actually enable, and the one where the generic body reads
+ // visibly worse — `notify.event` renders the title over the TRIGGER's
+ // description, while this reads the payload's own names and says what is
+ // starting, when, and what arc it belongs to. Same argument `notify.team-post`
+ // makes beside the generic body, one feature along.
+ //
+ // **Every optional line is one token on its own**, which is this template
+ // language's whole conditional (see `email.text`: a block whose content is a
+ // single absent variable renders nothing, in both parts). A standalone event
+ // has no `seriesName` and its line disappears rather than reading "Part of .".
+ //
+ // **No `{{actionUrl}}` and no button, deliberately.** There is no public event
+ // page until Phase 14, so the six public triggers declare no `url` variable at
+ // all (see `coreTriggers.js`), and a button here would render as an inert grey
+ // label in every mail — worse than none, because it advertises a link the
+ // reader cannot follow. Phase 14 adds the variable and the block together.
+ {
+ key: 'notify.event-started',
+ name: 'Event starting',
+ channel: 'email',
+ protected: false,
+ seedVersion: 1,
+ subject: '{{title}} is starting',
+ variables: [
+ { name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
+ { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
+ { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
+ { name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
+ { name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
+ ],
+ blocks: [
+ heading('h', '{{title}}'),
+ text('summary', '{{summary}}'),
+ text('when', '{{startsAtLabel}}', { muted: true }),
+ text('series', '{{seriesName}}', { muted: true }),
+ divider('rule'),
+ button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
+ ],
+ },
]
/** @returns {object|null} the seed definition for `key`. */
diff --git a/server/src/events/announce.js b/server/src/events/announce.js
new file mode 100644
index 0000000..e4813f0
--- /dev/null
+++ b/server/src/events/announce.js
@@ -0,0 +1,225 @@
+// ── A run's lifecycle, told to the engagement engine ───────────────────────
+//
+// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life
+// become seven `event.` triggers, and **Events owns none of the delivery**.
+//
+// That sentence is the whole design and it is worth being exact about what it
+// buys. Nothing in this file knows what email is, whether anyone is subscribed,
+// what a template says, or how often somebody may be mailed. It says a thing
+// happened, with the facts the declaration asked for; an operator's rule decides
+// the rest. Every announcement channel the platform has — email, the in-app
+// inbox, content-free push tickles, Discord and the town crier through the
+// announce legs — arrives for free the day a rule points at one, and none of
+// them arrives by anything in `events/` growing a second delivery path.
+//
+// **Nothing here throws and nothing here is awaited for its answer.** `emit`
+// itself is fire-and-forget by construction (see `engagementEmit`'s header) —
+// the whole point of the seam is that the emitter does not wait on rule lookups
+// and a dozen inserts. What IS awaited here is the read that assembles the
+// payload, and it is wrapped: a run must not fail to start because the row that
+// says what it is called could not be read.
+//
+// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for
+// real with announcements ceilinged to `staff`". Every emit below carries
+// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the
+// same rules are evaluated, the same log lines are written — and the only rules
+// that survive the G24 gate are ones whose audience a staff member is in. A
+// rehearsal that emitted nothing would be a rehearsal of everything except the
+// announcements, which are the part most worth rehearsing.
+
+const definitionsDb = require('../model/events/eventDefinitions.db')
+const participantsDb = require('../model/events/eventRunParticipants.db')
+const logDb = require('../model/events/eventRunLog.db')
+const engagementEmit = require('../utils/engagementEmit')
+const log = require('../utils/logger')('events')
+
+// §I, and the one place a rehearsal differs from the real thing on the announce
+// path. `staff` rather than `admin` because a rehearsal is the event team's
+// dress run and a moderator on it should see what an attendee would.
+const REHEARSAL_CEILING = 'staff'
+
+/**
+ * The start time written out in the shard-local zone, for a mail to read.
+ *
+ * **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1
+ * convention 1). `startsAt` also goes down the wire as a `datetime`, which the
+ * seam normalises to an ISO string — right as data and wrong in a sentence — and
+ * a template has no logic with which to format one. The zone is the shard's own,
+ * because "8pm" means the shard's evening to everyone reading it and the
+ * recipient's browser is not in the room when a mail is rendered.
+ *
+ * A bad zone answers null rather than throwing: `Intl` rejects an unknown
+ * identifier, and an event whose timezone column holds a typo must still
+ * announce. The variable is optional and its block is one token, so an absent
+ * label renders as nothing at all rather than as a broken line.
+ */
+function startsAtLabel(at, zone) {
+ const when = at instanceof Date ? at : new Date(at)
+ if (Number.isNaN(when.getTime())) return undefined
+ try {
+ const text = new Intl.DateTimeFormat('en-GB', {
+ timeZone: zone || 'UTC',
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ hour: 'numeric',
+ minute: '2-digit',
+ // Explicit rather than left to the locale, because `en-GB` would otherwise
+ // render midnight as "00:00" while the schedule editor beside it writes
+ // "12:00 AM" — one event, two spellings of the same instant.
+ hour12: true,
+ }).format(when)
+ return `${text} (${zone || 'UTC'})`
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * The facts every `event.` trigger shares, read once per emit.
+ *
+ * A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no
+ * series, no summary — so the definition is fetched here rather than threaded
+ * through every call site in the runner. It is one indexed read per lifecycle
+ * transition, which is a handful per run.
+ */
+async function baseFor(run) {
+ const definition = await definitionsDb.getById(run.definition_id)
+ if (!definition) return null
+ return {
+ runId: String(run.id),
+ title: definition.title,
+ summary: definition.summary || undefined,
+ seriesName: definition.series_name || undefined,
+ timezone: run.timezone || definition.timezone || undefined,
+ definition,
+ }
+}
+
+/**
+ * Fire one lifecycle trigger.
+ *
+ * `extra` is merged over the shared facts and may drop any of them — a payload
+ * key set to `undefined` is simply absent, and `validatePayload` treats absent
+ * and null alike, so a trigger that declares fewer variables than this assembles
+ * is not a problem: undeclared keys are dropped at the seam and logged as a
+ * debug line rather than refused.
+ *
+ * Answers nothing. Every caller is a transition in the runner and none of them
+ * has anything it could correctly do with a failure of core's own notification
+ * bookkeeping.
+ */
+async function fire(run, triggerId, extra = {}) {
+ try {
+ const base = await baseFor(run)
+ if (!base) {
+ // The definition is gone. `event_runs.definition_id` cascades on delete, so
+ // this is a race with an archive rather than an ordinary state — nothing to
+ // announce and nothing broken.
+ return
+ }
+ const { definition, ...facts } = base
+ const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined
+
+ engagementEmit.emit('core', triggerId, {
+ // The run, not the definition. Two occurrences of a weekly event are two
+ // subjects, so last week's mail does not throttle this week's — and within
+ // one run a cooldown means "at most one line an hour about THIS", which is
+ // the sentence an operator writing `phase.changed` actually wants.
+ subject: String(run.id),
+ // Bounded and stable, because it ends up in a signed unsubscribe token that
+ // will sit in a mailbox for months. A run id is both.
+ scopeKey: `event:${run.id}`,
+ ceiling,
+ data: { ...facts, ...extra },
+ })
+
+ // Written here rather than at each call site: what an operator wants in the
+ // run log is that the run SAID something happened, and with what bound. How
+ // many people were told is the engagement engine's own log line and its own
+ // decision — a run log that claimed to know the number would be reporting a
+ // decision it does not make.
+ await logDb.write({
+ runId: run.id,
+ kind: 'announcement.emitted',
+ phase: run.current_phase || null,
+ detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) },
+ })
+ } catch (err) {
+ log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message })
+ }
+}
+
+// ── One function per moment, so the runner names a moment and not a payload ──
+//
+// The alternative — `fire(run, 'event.run.started', { … })` at each call site —
+// would put the payload assembly in `eventRunner.js`, where a change to a
+// declaration becomes a change to the runner. These are the seam.
+
+const runScheduled = (run) =>
+ fire(run, 'event.run.scheduled', {
+ startsAt: run.scheduled_for,
+ startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone),
+ })
+
+const runStarted = (run, startedAt) => {
+ const at = startedAt || run.started_at || new Date()
+ return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) })
+}
+
+const phaseChanged = (run, { phase, label, index, count }) =>
+ fire(run, 'event.phase.changed', {
+ phase,
+ phaseLabel: label || phase,
+ // One-based, because it is read by a human in a sentence. Every caller
+ // passes the zero-based index it already has and the conversion is here, in
+ // one place, rather than at three call sites where two of them would drift.
+ phaseIndex: index + 1,
+ phaseCount: count,
+ })
+
+const runEnding = (run) => fire(run, 'event.run.ending')
+
+async function runCompleted(run, endedAt) {
+ // Counted at emit rather than carried by the caller: the last thing a run does
+ // before completing is its teardown, and a module's collect step may have
+ // written rows within the same tick.
+ let participantCount = 0
+ try {
+ participantCount = await participantsDb.countForRun(run.id)
+ } catch (err) {
+ // Declared `required`, so it has to be a number. Zero is the honest answer
+ // for a count that could not be read, and it is also the answer for the far
+ // more common case of a run nothing collected for.
+ log.warn('participant count unavailable for announcement', { run: run.id, message: err.message })
+ }
+ const started = run.started_at ? new Date(run.started_at) : null
+ const ended = endedAt ? new Date(endedAt) : new Date()
+ const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0
+ return fire(run, 'event.run.completed', { participantCount, durationMinutes })
+}
+
+const runCancelled = (run, reason) =>
+ fire(run, 'event.run.cancelled', { reason: reason || undefined })
+
+const runFailed = (run, error) =>
+ fire(run, 'event.run.failed', {
+ phase: run.current_phase || undefined,
+ error: error || run.last_error || undefined,
+ // The one destination that exists today. See `coreTriggers.js`'s note above
+ // the six public declarations for why none of them has one.
+ runUrl: `/admin/events/runs/${run.id}`,
+ })
+
+module.exports = {
+ fire,
+ startsAtLabel,
+ runScheduled,
+ runStarted,
+ phaseChanged,
+ runEnding,
+ runCompleted,
+ runCancelled,
+ runFailed,
+ REHEARSAL_CEILING,
+}
diff --git a/server/src/events/dispatch.js b/server/src/events/dispatch.js
index 3ac9e32..f1fa8aa 100644
--- a/server/src/events/dispatch.js
+++ b/server/src/events/dispatch.js
@@ -61,7 +61,8 @@ function withDeadline(fn, ms, actionId) {
}
/**
- * Turn a raw `perform()` answer into `{ outcome, error?, holdSeconds?, resources? }`.
+ * Turn a raw `perform()` answer into
+ * `{ outcome, error?, holdSeconds?, resources?, participants? }`.
*
* Exported and pure, so the classification rules are testable without a registry,
* a database or a clock — which matters because they are the rules that decide
@@ -97,7 +98,12 @@ function classify(result, actionId) {
// never names a verb. `core.cue` and `core.wait` reach them through the same
// door Phase 7 opens to a module's own long-running action.
if (result.await === 'human') {
- return { outcome: 'parked', error: null, resources: result.resources || [] }
+ return {
+ outcome: 'parked',
+ error: null,
+ resources: result.resources || [],
+ participants: result.participants || [],
+ }
}
let holdSeconds = 0
@@ -109,7 +115,17 @@ function classify(result, actionId) {
holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
}
- return { outcome: 'done', error: null, holdSeconds, resources: result.resources || [] }
+ // `participants` rides beside `resources` and on the same two success shapes
+ // (Phase 10). It is carried rather than interpreted here: what a member key
+ // means is the module's business, and this file's whole job is to know
+ // nothing about the verb it just called.
+ return {
+ outcome: 'done',
+ error: null,
+ holdSeconds,
+ resources: result.resources || [],
+ participants: result.participants || [],
+ }
}
/**
diff --git a/server/src/events/participants.js b/server/src/events/participants.js
new file mode 100644
index 0000000..c63efe9
--- /dev/null
+++ b/server/src/events/participants.js
@@ -0,0 +1,162 @@
+// ── Recording who took part ────────────────────────────────────────────────
+//
+// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of
+// `events/ledger.js`: that file records what a run did to the world, this one
+// records who it happened to.
+//
+// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An
+// action answers `{ ok: true, participants: [...] }` and the runner writes them
+// beside the resources, on the same two success shapes, through the same
+// classify → record path. There is no `ctx.events.participants` API and no route:
+// a second write path into a run core is mid-tick on would be a second thing that
+// can race the claim, for a caller that does not exist until a module can source
+// the data at all (Phase 11's plugin-side participation ledger; Phase 12's
+// collect step is the first consumer).
+//
+// **Core cannot source a participant and does not try.** §J: `member_key` is
+// module-opaque, `user_id` is filled in by whoever knows the link table. For
+// module-uo that is `shard_links`; for another game it is something else, and a
+// core that guessed would be one game's identity model compiled into core. So a
+// module reports both halves, or reports the key alone and the row stays
+// anonymous — which is the honest record of an unlinked player who turned up.
+//
+// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s
+// posture and for exactly its reason: a malformed participant will be just as
+// malformed on the second attempt, and failing the step would re-dispatch a
+// world write that already happened. Rejections are logged and surfaced on the
+// run log so an author can see what their module sent.
+
+const participantsDb = require('../model/events/eventRunParticipants.db')
+const log = require('../utils/logger')('events')
+
+// Bounded to the column, and refused rather than truncated: a truncated member
+// key is a different participant, and under `uq_evpart_member` it would silently
+// merge two people into one row.
+const MAX_MEMBER_KEY = 190
+
+// The most one step may report. A run's participants are people, and a step
+// answering with a hundred thousand of them is a module bug rather than a very
+// popular event — one that would otherwise spend a tick's whole budget on
+// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement
+// engine is 5000 for the same class of reason and this matches it deliberately:
+// the two bound the same thing, a list of users one call may assert.
+const MAX_PER_STEP = 5000
+
+/**
+ * Turn one entry of a module's `participants` array into a row, or say why not.
+ */
+function normalise(entry, actionId) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ return { ok: false, reason: `${actionId} reported a participant that is not an object` }
+ }
+
+ const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey)
+ if (!memberKey || memberKey.length > MAX_MEMBER_KEY) {
+ return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` }
+ }
+
+ // Optional, and checked rather than coerced. A `userId` is a foreign key into
+ // `users`, so a module that passed a character serial here would either fail
+ // the insert or — worse, if the number happened to be a real user — attribute
+ // somebody else's attendance to a stranger.
+ let userId = null
+ if (entry.userId !== undefined && entry.userId !== null) {
+ if (!Number.isInteger(entry.userId) || entry.userId < 1) {
+ return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` }
+ }
+ userId = entry.userId
+ }
+
+ // `score` is optional and defaults to 0 — a run that only records attendance
+ // is a run where everybody scored nothing, which is a true statement and a
+ // renderable table. Non-finite is refused rather than coerced: `NaN` written
+ // into a DECIMAL would either throw at the driver or land as 0, and a 0 that
+ // meant "the module sent nonsense" is indistinguishable from an honest zero.
+ let score = 0
+ if (entry.score !== undefined && entry.score !== null) {
+ const n = Number(entry.score)
+ if (!Number.isFinite(n)) {
+ return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` }
+ }
+ score = n
+ }
+
+ let joinedAt = null
+ if (entry.joinedAt !== undefined && entry.joinedAt !== null) {
+ const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt)
+ if (Number.isNaN(at.getTime())) {
+ return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` }
+ }
+ joinedAt = at
+ }
+
+ return {
+ ok: true,
+ row: {
+ memberKey,
+ userId,
+ score,
+ // Opaque, exactly like a resource's payload: core stores it and never
+ // reads it. Anything but an object is dropped rather than refused —
+ // `meta` is decoration on a row whose identity is already valid, and
+ // losing an event's whole attendance over a stray string would be the
+ // wrong trade.
+ meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null,
+ joinedAt,
+ },
+ }
+}
+
+/**
+ * Record what a module said about who took part.
+ *
+ * Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a
+ * step that changed the world has changed it, and a bookkeeping failure must not
+ * become a retry of a world write.
+ */
+async function recordAnswer({ run, step, action, participants }) {
+ const out = { recorded: 0, rejected: [] }
+ const list = Array.isArray(participants) ? participants : []
+ if (!list.length) return out
+
+ if (list.length > MAX_PER_STEP) {
+ // Refused whole rather than truncated. Half a leaderboard silently cut at
+ // five thousand is worse than none: the table would look complete and be
+ // wrong, and nothing downstream could tell.
+ const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may`
+ log.warn('event participants refused', { run: run.id, step: step.id, reason })
+ return { recorded: 0, rejected: [reason] }
+ }
+
+ // **Deduplicated in memory before the write.** One step reporting the same
+ // member twice is a module bug, and letting both reach the upsert would make
+ // the LAST one win silently. Refusing the step would be worse — the other
+ // ninety-nine participants are fine — so the first wins and the duplicate is
+ // named, which is a thing an author can act on.
+ const seen = new Set()
+
+ for (const entry of list) {
+ const parsed = normalise(entry, action.id)
+ if (!parsed.ok) {
+ out.rejected.push(parsed.reason)
+ log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason })
+ continue
+ }
+ if (seen.has(parsed.row.memberKey)) {
+ out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`)
+ continue
+ }
+ seen.add(parsed.row.memberKey)
+ try {
+ await participantsDb.record({ runId: run.id, ...parsed.row })
+ out.recorded += 1
+ } catch (err) {
+ out.rejected.push(err.message)
+ log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message })
+ }
+ }
+
+ return out
+}
+
+module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP }
diff --git a/server/src/model/announceJobs/announceJobs.db.js b/server/src/model/announceJobs/announceJobs.db.js
index 7c51a20..e4804d5 100644
--- a/server/src/model/announceJobs/announceJobs.db.js
+++ b/server/src/model/announceJobs/announceJobs.db.js
@@ -11,7 +11,7 @@
const { query } = require('../../utils/db')
-const COLS = 'id, post_id, status, created_at, updated_at'
+const COLS = 'id, post_id, run_id, status, created_at, updated_at'
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
async function legsFor(jobIds) {
@@ -34,8 +34,15 @@ async function attachLegs(jobs) {
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
// an empty list is legal and yields a job with nothing to deliver.
-async function create(postId, legs = []) {
- const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
+//
+// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than
+// the one a news publish enqueues. It changes nothing about how the job is
+// dispatched, retried or rolled up — the whole point of reusing this pipeline is
+// that an event announcement gets the legs, the backoff and the classification
+// already written — and everything it does change is in the two places below
+// that ask "whose job is this".
+async function create(postId, legs = [], { runId = null } = {}) {
+ const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId])
const jobId = Number(res.insertId)
if (legs.length > 0) {
const values = legs.map(() => '(?, ?)').join(', ')
@@ -65,9 +72,15 @@ async function findById(id) {
return (await attachLegs(rows))[0]
}
+// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may
+// now have more than one — the news publish enqueued one, and an event linked the
+// same post later — and every caller of this function is the post admin panel or
+// its retry button, which are about the news announcement. Without the clause the
+// panel would silently start rendering an event's job the moment one existed, and
+// the retry button would retry that instead.
async function findByPostId(postId) {
const rows = await query(
- `SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
+ `SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL ORDER BY id DESC LIMIT 1`,
[postId],
)
if (rows.length === 0) return null
diff --git a/server/src/model/announceJobs/announceJobs.model.js b/server/src/model/announceJobs/announceJobs.model.js
index a82ffcd..31c63c2 100644
--- a/server/src/model/announceJobs/announceJobs.model.js
+++ b/server/src/model/announceJobs/announceJobs.model.js
@@ -45,6 +45,33 @@ async function enqueueIfNeeded(post, transition) {
}
}
+/**
+ * Enqueue an announcement a RUN asked for (EVENTS.md §J, Phase 10).
+ *
+ * The same job, the same legs, the same worker — so the town crier and Discord
+ * come free, with their retry and their classification, rather than an event
+ * growing a second delivery pipeline that would need both again and get them
+ * subtly wrong. Two things differ, and both are about not standing on the news
+ * pipeline's toes:
+ *
+ * **The post's back-pointer is written only when it has none.** `announce_job_id`
+ * is what the post admin panel reads and what `shouldEnqueue` guards on, so
+ * moving it to an event's job would make a re-published post announce itself
+ * again. A post that has never been announced gains the pointer, because then
+ * this job IS its announcement and the panel should show it.
+ *
+ * **`announced_at` is not stamped by a run's job** — see `refreshStatus`.
+ *
+ * Returns the new job id.
+ */
+async function enqueueForRun(postId, runId) {
+ const jobId = await db.create(postId, registries.announceLegIds(), { runId })
+ const post = await posts.getById(postId)
+ if (post && !post.announce_job_id) await posts.linkAnnounceJob(postId, jobId)
+ log.info('announce job enqueued for a run', { jobId, postId, runId })
+ return jobId
+}
+
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
// attempt count and schedule the next run (or fail the leg once the cap is hit).
@@ -82,7 +109,12 @@ async function refreshStatus(jobId) {
const status = logic.rollupStatus(job.legs.map((l) => l.status))
if (status !== job.status) await db.setStatus(jobId, status)
job.status = status
- if (status === 'done') {
+ // **A run's job does not stamp the post** (Phase 10). `announced_at` means
+ // "when this post was announced", and an event that links a three-week-old
+ // news article would otherwise rewrite that to today — making the post admin
+ // panel report a publication date it does not have. The event's own record of
+ // having announced is the run log line and the job's `run_id`.
+ if (status === 'done' && !job.run_id) {
try {
await posts.markAnnounced(job.post_id)
} catch (err) {
@@ -127,6 +159,7 @@ async function getByPostId(postId) {
module.exports = {
enqueue,
+ enqueueForRun,
shouldEnqueue,
enqueueIfNeeded,
recordOutcome,
diff --git a/server/src/model/events/eventRunControls.model.js b/server/src/model/events/eventRunControls.model.js
index a0ad08e..23763fe 100644
--- a/server/src/model/events/eventRunControls.model.js
+++ b/server/src/model/events/eventRunControls.model.js
@@ -39,6 +39,7 @@
const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
+const announce = require('../../events/announce')
const gatesDb = require('./eventPhaseGates.db')
const resourcesDb = require('./eventRunResources.db')
const gates = require('../../events/gates')
@@ -195,6 +196,16 @@ async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { i
},
})
+ // **After the guarded transition, so exactly one caller announces** (Phase
+ // 10). Two moderators pressing cancel in the same second both reach the log
+ // write; only one of them wins `transition`, and the loser has already
+ // returned a 409 above.
+ //
+ // The operator's `reason`, not the run's `last_error` — `cancel` takes a
+ // sentence a human typed for other humans, and the diagnostic string that
+ // ends up in `last_error` would read as gibberish in a mail.
+ await announce.runCancelled(run, note)
+
// **The teardown is not done here, and the request does not wait for it.**
// Cleanup is one leg of the runner's tick over terminal runs (§L), which is
// what makes it survive a process that dies halfway through it — and a cancel
diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js
index 6abb204..19486a1 100644
--- a/server/src/model/events/eventRunLog.db.js
+++ b/server/src/model/events/eventRunLog.db.js
@@ -57,6 +57,14 @@ const KINDS = [
'cleanup.failed', // a group did not, with the reason and how it was left
'cleanup.swept', // one pass over a run's ledger, and what it found
'cleanup.retry', // a human cleared the attempt counter and asked again
+ // Phase 10's four: the integrations. `announcement.emitted` is a line about
+ // what the run SAID happened, not about who was told -- the engagement engine
+ // owns that decision and logs its own, and a run log that claimed to know how
+ // many mails went out would be reporting a decision it does not make.
+ 'participants.recorded', // a step reported who took part, and they are recorded
+ 'results.published', // the results table was ranked and stamped
+ 'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
+ 'announcement.enqueued', // a post was linked to this run and queued on the legs
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
diff --git a/server/src/model/events/eventRunParticipants.db.js b/server/src/model/events/eventRunParticipants.db.js
new file mode 100644
index 0000000..4c54f88
--- /dev/null
+++ b/server/src/model/events/eventRunParticipants.db.js
@@ -0,0 +1,119 @@
+// ── event_run_participants — SQL only ──────────────────────────────────────
+//
+// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
+// §D's core tables: who took part in a run, and how well.
+//
+// **Core writes this table and never sources it.** A `member_key` is
+// module-opaque, exactly as a resource's `ref` is — core cannot map a character
+// name onto a user row and must not try, because that mapping is one game's
+// (`shard_links`, for module-uo) and would be that game compiled into core. A
+// module that knows both halves reports both; core stores what it is told.
+//
+// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
+// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
+// collect that duplicated its rows would double a leaderboard. It is the same
+// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
+// along, with the difference that a re-report may carry a BETTER score and must
+// win rather than be ignored.
+
+const { query } = require('../../utils/db')
+const { parseJson } = require('./eventJson')
+
+const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
+ created_at, updated_at`
+
+// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
+// arrives as a JS number; the coercion is belt to that braces and costs nothing.
+// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
+// every caller wants the object rather than the string the driver returns.
+const hydrate = (row) =>
+ row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
+
+/**
+ * Record one participant, or update the one already recorded.
+ *
+ * **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
+ * the point of the column: it is when this participant first appeared, and a
+ * second report — a later collect, a corrected score — must not rewrite it. The
+ * same applies to `rank_at`, which is not touched here at all: ranking is
+ * `core.results.publish`'s job and a re-report between two publications must not
+ * silently invent a rank nobody computed.
+ *
+ * `user_id` DOES move on a re-report, deliberately: a player who linked their
+ * website account between two collects should stop being anonymous, and the
+ * module is the only thing that can know they did.
+ *
+ * **It answers nothing, and the reason is a trap worth naming.** The obvious
+ * return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
+ * update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
+ * sends it: with it, a re-report whose values are identical also answers 1, so
+ * the flag would report every idempotent retry as a fresh participant. The
+ * caller wants "how many were reported" anyway, which it already knows from the
+ * length of its own list.
+ */
+async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
+ await query(
+ `INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
+ VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
+ ON DUPLICATE KEY UPDATE
+ user_id = VALUES(user_id),
+ score = VALUES(score),
+ meta = VALUES(meta)`,
+ [runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
+ )
+}
+
+/** One run's participants, best first. The results table, and the console's. */
+async function listForRun(runId, limit = 500) {
+ const rows = await query(
+ `SELECT ${COLUMNS} FROM event_run_participants
+ WHERE run_id = ?
+ ORDER BY score DESC, joined_at ASC, id ASC
+ LIMIT ?`,
+ [runId, limit],
+ )
+ return rows.map(hydrate)
+}
+
+/** How many the run has. Its own query because the trigger payload needs only this. */
+async function countForRun(runId) {
+ const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
+ return Number(rows[0]?.n || 0)
+}
+
+/**
+ * Number every participant of one run by score, best first.
+ *
+ * **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
+ * followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
+ * wrong here in a way that would have passed every test that did not run twice
+ * concurrently: `query()` takes a connection from the pool per call and releases
+ * it, so the session variable is set on one connection and read on whichever the
+ * second call happens to get. A window function needs no session state at all.
+ *
+ * **The ordering is total.** `score DESC` alone leaves ties in whatever order the
+ * engine felt like, so two publications of the same run would hand out different
+ * ranks to the same two people; `joined_at` then `id` breaks every tie the same
+ * way every time, which is what makes re-publishing idempotent rather than a
+ * reshuffle.
+ *
+ * Ties share nothing — two people on the same score get consecutive ranks rather
+ * than a dense or competition ranking. That is a presentation decision belonging
+ * to whatever renders the table; what this owes is a stable number.
+ */
+async function rankRun(runId) {
+ const result = await query(
+ `UPDATE event_run_participants p
+ JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
+ FROM event_run_participants
+ WHERE run_id = ?) r ON r.id = p.id
+ SET p.rank_at = r.rk`,
+ [runId],
+ )
+ // The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
+ // than rows changed — which is the number wanted here. Re-publishing a run
+ // whose ranks are already correct answers "12 ranked", not "0".
+ return Number(result.affectedRows || 0)
+}
+
+module.exports = { record, listForRun, countForRun, rankRun }
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index 5b224ff..86659c8 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -412,6 +412,20 @@ async function setCleanupStatus(id, to, from = null) {
return Number(result?.affectedRows || 0) === 1
}
+/**
+ * Stamp this run's results table as published (EVENTS.md §J, Phase 10).
+ *
+ * **Unguarded, and re-stampable.** `core.results.publish` is an ordinary step
+ * that an author may place more than once — before an announcement and again
+ * after a late correction — and each publication is a real one whose moment is
+ * worth recording. Guarding it on `IS NULL` would make the second silently do
+ * nothing while the ranking beside it did move, which is the worst of both.
+ */
+async function markResultsPublished(id, at = new Date()) {
+ const result = await query('UPDATE event_runs SET results_published_at = ? WHERE id = ?', [at, id])
+ return Number(result?.affectedRows || 0) === 1
+}
+
/**
* Runs whose start instant passed more than their own grace window ago (§E, §L).
*
@@ -515,6 +529,7 @@ module.exports = {
transition,
setHealth,
setCleanupStatus,
+ markResultsPublished,
concurrencyHolder,
reclaimStale,
terminalBefore,
diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js
index dd00149..9a11371 100644
--- a/server/src/model/events/eventRuns.model.js
+++ b/server/src/model/events/eventRuns.model.js
@@ -28,6 +28,7 @@ const versionsDb = require('./eventVersions.db')
const settingsDb = require('./eventActionSettings.db')
const budgetDb = require('./eventRunBudget.db')
const resourcesDb = require('./eventRunResources.db')
+const participantsDb = require('./eventRunParticipants.db')
const authorize = require('../../events/authorize')
const MAX_SCOPE = 190
@@ -205,12 +206,13 @@ async function create(
async function detail(runId) {
const run = await db.getById(runId)
if (!run) return null
- const [steps, counts, gateRows, budget, resources] = await Promise.all([
+ const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([
stepsDb.listForRun(runId),
stepsDb.statusCounts(runId),
gatesDb.listForRun(runId),
budgetDb.forRun(runId),
resourcesDb.forRun(runId),
+ participantsDb.listForRun(runId),
])
const now = new Date()
return {
@@ -259,6 +261,27 @@ async function detail(runId) {
// than over the list above — a placeholder left standing by a lost
// acknowledgement is exactly the case `cleanup_status` must not call clean.
unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length,
+ // Who took part, best first (Phase 10). Returned on every run rather than
+ // only on a published one: the console's question is "what did this event
+ // record", and a run whose module has collected but whose author never
+ // placed a publish step is exactly the case an operator needs to see. What
+ // `results_published_at` on the run row then says is whether anyone OUTSIDE
+ // this screen may read it — which is Phase 14's question, not this one's.
+ //
+ // **`rank` is `rank_at`, renamed at the boundary and not in the column.**
+ // `rank` is a reserved word in MariaDB 10.2+ (it is the window function),
+ // so the column carries the suffix and the API carries the name a client
+ // wants. The alternative — backticking the column at every use — is one
+ // forgotten pair of backticks away from a syntax error in a query nobody
+ // runs until a run completes at four in the morning.
+ participants: attendees.map((p) => ({
+ memberKey: p.member_key,
+ userId: p.user_id,
+ score: p.score,
+ rank: p.rank_at,
+ joinedAt: p.joined_at,
+ meta: p.meta,
+ })),
}
}
diff --git a/server/src/model/posts/posts.db.js b/server/src/model/posts/posts.db.js
index 6e6b499..adf9213 100644
--- a/server/src/model/posts/posts.db.js
+++ b/server/src/model/posts/posts.db.js
@@ -12,6 +12,19 @@ async function listPublished(category) {
)
}
+// Every published post, across categories, newest first — the option source
+// behind `core.announce.post`'s `postId` param (EVENTS.md §F, Phase 10). Its own
+// query rather than a loop over `listPublished` because an authoring dropdown
+// wants one bounded, ordered list and needs neither the body nor the excerpt: a
+// hundred posts' bodies would be a megabyte of HTML sent to draw a `
+ {/* ── The live cap meter (Phase 13) ──
+ Above the timeline rather than under it, because what a plan draws is a
+ fact about the whole plan and an author scrolling twelve steps to find
+ out they are over is an author who finds out too late. */}
+
+
{/* ── The phase timeline ── */}
Phases
@@ -754,6 +1309,20 @@ export default function EventEditor() {
cannot change once runs exist.
+ {/* §I asks the timeline for "phases in order, each with its steps, its
+ advance condition, its cap draw and its failure policy". This is
+ the cap draw, and it is the phase's own rather than a share of the
+ total: an author moving a step between phases is asking exactly
+ this question. */}
+ {(drawByPhase.get(pi) || []).length > 0 && (
+
+ )}
+
{/* ── The advance condition (Phase 5) ──
A gate is an ADDITIONAL condition and never a replacement, which is
what the caption has to say: a phase whose steps are still running
@@ -779,8 +1348,16 @@ export default function EventEditor() {
<>
@@ -825,6 +1391,9 @@ export default function EventEditor() {
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)
+ // Phase 13. Which editor this step gets, and — when it is not the
+ // author's choice — why.
+ const mode = paramsMode(step, action)
return (
- Pick an action and its parameters are listed here, straight from what the
- module declared.
-
+ {/* ── The params (Phase 13) ──
+ A form of the action's own declaration, with the JSON box
+ kept as the escape hatch. A step the form cannot hold
+ without losing something opens in JSON and says why — the
+ condition builder's rule, and the same one, because
+ dropping an undeclared param and flattening a nested
+ condition are the same failure: a save that looks clean
+ and means something else. */}
+
+ Pick an action and its fields appear here, straight from what the module
+ declared.
+
+ )}
+
+ ) : (
+ <>
+
)
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
index 14a7477..04dc984 100644
--- a/client/test/eventAuthoring.test.js
+++ b/client/test/eventAuthoring.test.js
@@ -23,6 +23,17 @@ import {
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
+ blankWhere,
+ whereFormFrom,
+ paramsRenderable,
+ paramsMode,
+ paramValue,
+ setParam,
+ datetimeInputValue,
+ priceBodyFrom,
+ worthPricing,
+ PARAM_FORM,
+ PARAM_JSON,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
@@ -479,29 +490,97 @@ test('a gate round-trips through the form without losing the other shape', () =>
assert.equal(ADVANCE_KINDS[0].value, '')
})
-test('advancePayload sends one shape, and only reports a JSON error', () => {
+test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
const errors = []
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
assert.deepEqual(
- advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', whereText: '' }, 'Phase 1', errors),
+ advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
{ on: 'uo.champ.boss_up', count: 2 },
'an empty predicate is omitted, not sent as an empty object',
)
assert.equal(errors.length, 0)
- advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
- assert.equal(errors.length, 1)
- assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
-
- // Whether the predicate is VALID is the server's answer, named variable and
- // all. This only refuses text that cannot be put in a request.
- const clean = []
+ // Whether the predicate is VALID is still the server's answer, named variable
+ // and all \u2014 the builder only offers what the trigger declares, and a variable
+ // that has gone away comes back named from the save.
assert.deepEqual(
- advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
+ advancePayload(
+ {
+ kind: 'on',
+ on: 'x',
+ count: 1,
+ ...blankWhere(),
+ whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
+ },
+ 'Phase 1',
+ errors,
+ [{ name: 'nope', type: 'int' }],
+ ),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
- assert.equal(clean.length, 0)
+ assert.equal(errors.length, 0)
+})
+
+test('the builder coerces each literal to the type the trigger declared', () => {
+ // The trap this closes: every value in an HTML input is a string, and
+ // `{ cmp: 'gt', value: "5" }` against an int variable is refused by
+ // engagement/conditions.js. Without this the author reads an error about JSON
+ // rather than about what they typed.
+ const built = advancePayload(
+ {
+ kind: 'on',
+ on: 'x',
+ count: 1,
+ ...blankWhere(),
+ whereOp: 'or',
+ whereRows: [
+ { variable: 'tier', cmp: 'gte', value: '3' },
+ { variable: 'region', cmp: 'in', value: 'Yew, Britain' },
+ ],
+ },
+ 'Phase 1',
+ [],
+ [{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
+ )
+ assert.deepEqual(built.where, {
+ op: 'or',
+ nodes: [
+ { variable: 'tier', cmp: 'gte', value: 3 },
+ { variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
+ ],
+ })
+})
+
+test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
+ // `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
+ // and an author would have no way to know the save had done it. The condition
+ // builder's own rule, and this is the same function.
+ const nested = {
+ op: 'and',
+ nodes: [
+ { variable: 'region', cmp: 'eq', value: 'Yew' },
+ { op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
+ ],
+ }
+ const form = whereFormFrom(nested)
+ assert.equal(form.whereEditable, false)
+ assert.deepEqual(form.whereRows, [])
+
+ const errors = []
+ const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
+ assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
+ assert.equal(errors.length, 0)
+
+ // And the text is still the thing that can fail to parse, which is the only
+ // reason this path keeps an error channel at all.
+ advancePayload(
+ { kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
+ 'Phase 2 "Boss"',
+ errors,
+ )
+ assert.equal(errors.length, 1)
+ assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
})
test('a phase with no gate sends no `advance` key', () => {
@@ -602,3 +681,159 @@ test("the log renders Phase 6's three kinds, and a refusal does not read as a fa
/Version 2 passed its dry run — scheduled occurrences may start/,
)
})
+
+
+// ── Step params as a form (Phase 13) ──────────────────────────────
+//
+// The form is not a boundary either — `events/spec.js` still decides what may be
+// saved. What is tested here is the thing that would be wrong SILENTLY: a form
+// that drops a param it cannot draw, or writes a value the author never typed.
+
+const spawn = {
+ id: 'test.spawn',
+ label: 'Spawn',
+ params: [
+ { name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
+ { name: 'count', type: 'int', required: true, example: 8 },
+ { name: 'tame', type: 'boolean', required: false, example: false },
+ { name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
+ ],
+}
+
+const stepWith = (params, over = {}) => ({
+ actionId: 'test.spawn',
+ paramsText: JSON.stringify(params, null, 2),
+ ...over,
+})
+
+test('a step whose params the form can hold opens as a form', () => {
+ const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
+ assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
+})
+
+test('an author who chose JSON stays in JSON', () => {
+ const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, false, 'their choice, so no reason is shown')
+})
+
+test('a param the action does not declare FORCES the JSON box and says which', () => {
+ // The form would render four fields and post four values, having deleted
+ // `radius` — a save that looks clean and means something else. The save path
+ // refuses it by name, which is what the author needs to see.
+ const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, true)
+ assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
+})
+
+test('a value no single control can hold forces the JSON box', () => {
+ assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
+ assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
+})
+
+test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
+ const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, true)
+ assert.match(mode.reason, /not installed/)
+})
+
+test('a params box that is not JSON opens as JSON with the parse error', () => {
+ const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, true)
+ assert.match(mode.reason, /not valid JSON/)
+})
+
+test('paramsRenderable accepts a step with nothing in it', () => {
+ // A brand-new step with an optional-only action, and the empty case a form
+ // needs to survive before anybody has typed.
+ assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
+})
+
+test('setParam writes the type the param declared, not the string the input held', () => {
+ const step = stepWith({ creature: 'orc', count: 8 })
+ assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
+ assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
+ creature: 'orc',
+ count: 8,
+ tame: true,
+ })
+})
+
+test('a half-typed number is kept as typed rather than turned into NaN', () => {
+ // `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
+ // turning `-` into NaN while somebody types would either post a value they
+ // never wrote or make a negative impossible to enter. The server's type check
+ // then names the param.
+ const step = stepWith({ count: 8 })
+ assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
+})
+
+test('clearing a field REMOVES the key rather than posting an empty string', () => {
+ // `checkParams` treats undefined, null and '' alike — absent — so a required
+ // param left blank comes back as "is required", which is the error the author
+ // needs, instead of a type complaint about "".
+ const step = stepWith({ creature: 'orc', count: 8 })
+ assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
+})
+
+test('setParam leaves an unparseable box alone rather than overwriting it', () => {
+ // The only way to reach this is a race between the mode switch and a
+ // keystroke; silently replacing the text with `{ "count": 1 }` would destroy
+ // whatever the author was midway through writing.
+ const step = { actionId: 'test.spawn', paramsText: '{ not json' }
+ assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
+})
+
+test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
+ assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
+ assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
+ assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
+})
+
+test('a datetime is sliced to what the input wants, and anything else is empty', () => {
+ assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
+ assert.equal(datetimeInputValue(undefined), '')
+ assert.equal(datetimeInputValue(12), '')
+})
+
+// ── The meter's request (Phase 13) ────────────────────────────
+
+test('the price body carries the plan and nothing else', () => {
+ const form = formFromDefinition({
+ title: 'Invasion',
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [
+ { key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
+ { key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
+ ],
+ },
+ })
+ assert.deepEqual(priceBodyFrom(form), {
+ phases: [
+ { key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
+ { key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
+ ],
+ })
+})
+
+test('a step whose params do not parse is priced with none rather than dropped', () => {
+ // Dropping it would move every step after it up an ordinal, so the meter's
+ // "phase 2 step 3" would name a different step from the one on the screen.
+ const form = {
+ phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
+ }
+ assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
+})
+
+test('an empty plan is not worth pricing', () => {
+ // Otherwise the meter asks the server what nothing costs on every keystroke of
+ // the title field.
+ assert.equal(worthPricing({ phases: [] }), false)
+ assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
+ assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
+ assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
+})
diff --git a/server/routes.guards.json b/server/routes.guards.json
index b2c5dd5..3e83c5d 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -545,6 +545,15 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/price",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 31caf15..df698f0 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -241,6 +241,10 @@
"method": "GET",
"path": "/api/v1/admin/events/catalog/options/:sourceId"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/price"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs"
diff --git a/server/src/events/price.js b/server/src/events/price.js
new file mode 100644
index 0000000..8561d51
--- /dev/null
+++ b/server/src/events/price.js
@@ -0,0 +1,185 @@
+// ── The live cap meter ─────────────────────────────────────────────────────
+//
+// EVENTS.md §I, Phase 13: the step editor's "live cap meter". What would this
+// plan spend, and what does this deployment allow?
+//
+// **It is not the dry run, and the difference is the whole reason it exists.**
+// `verify.js` dispatches every step with `verify: true` — through the module,
+// and through the module to a sidecar and a game tick — and a pass against a
+// published version is RECORDED, because that record is what §K's gate reads
+// before letting a schedule start something unattended. Both of those are right
+// for an act an author performs once, deliberately, when the plan is finished.
+// Neither is right for a number that has to move while somebody types: a meter
+// on the dry run's path would put a shard round trip behind every keystroke and
+// would stamp `verified_at` from a form still being edited.
+//
+// So this file answers the half of the question core can answer ON ITS OWN:
+// `cost()` is a pure function of params (§F), and the caps come from the
+// switchboard. Nothing is dispatched, nothing is written, and no definition need
+// exist — the body is the spec in the author's hands, saved or not.
+//
+// **What it therefore cannot tell you** is everything the module knows: whether
+// the landmark exists, whether the creature is on the allowlist, whether the
+// shard is reachable. That is the dry run's, and the meter must not read as a
+// substitute for it — which is why the editor keeps both and labels them apart.
+//
+// ## Why the per-phase subtotal is here rather than computed in the browser
+//
+// §I asks the timeline for "its cap draw" per phase, and the arithmetic is
+// trivial — but the *inputs* are not in the browser. `cost()` runs on the server
+// and only on the server; a client that summed anything would first have to be
+// handed per-step costs, which is this same call. Returning the phase rollup
+// beside the total costs one pass over a list core has already walked.
+
+const authorize = require('./authorize')
+const settingsDb = require('../model/events/eventActionSettings.db')
+const registries = require('../modules/registries')
+const spec = require('./spec')
+
+/**
+ * Flatten `{ phases: [{ key, steps: [...] }] }` into the priceable steps.
+ *
+ * Bounded by the spec's own limits rather than by a number invented here: this
+ * route takes an unsaved spec, so it is reachable with a body the save path
+ * would refuse, and the paste guard has to be the same one.
+ */
+function flatten(body) {
+ const phases = Array.isArray(body?.phases) ? body.phases : []
+ if (phases.length > spec.MAX_PHASES) {
+ return { ok: false, error: `at most ${spec.MAX_PHASES} phases` }
+ }
+ const flat = []
+ for (const [index, phase] of phases.entries()) {
+ const steps = Array.isArray(phase?.steps) ? phase.steps : []
+ if (steps.length > spec.MAX_STEPS_PER_PHASE) {
+ return { ok: false, error: `at most ${spec.MAX_STEPS_PER_PHASE} steps in one phase` }
+ }
+ for (const [seq, step] of steps.entries()) {
+ flat.push({
+ // The key is what the editor groups by, and an unsaved phase may not
+ // have a valid one yet — so the ordinal is what is echoed back. A meter
+ // that could only address a phase whose key already validates would go
+ // blank exactly while somebody is naming it.
+ phase: index,
+ phaseKey: typeof phase?.key === 'string' ? phase.key : null,
+ seq,
+ actionId: typeof step?.actionId === 'string' ? step.actionId : '',
+ params: step && typeof step.params === 'object' && !Array.isArray(step.params) ? step.params : {},
+ })
+ }
+ }
+ if (flat.length > spec.MAX_STEPS) {
+ return { ok: false, error: `at most ${spec.MAX_STEPS} steps in one definition` }
+ }
+ return { ok: true, flat }
+}
+
+/**
+ * Price a spec.
+ *
+ * **A step core cannot price is reported, never treated as free.** Three things
+ * make one: no module registers the action, the action's `cost()` failed its own
+ * contract (`priceOf` answers `null`), or it prices a dimension nobody declared.
+ * All three make the totals below an UNDER-count, and a meter that silently
+ * under-counts is worse than no meter — it is a number an author trusts that is
+ * smaller than what will happen. So each one comes back in `unpriced` with the
+ * step it belongs to, and the client shows the meter as incomplete.
+ *
+ * The third is not a refusal to price: an action that spends `uo.creatures`
+ * spends it whether or not a module declared the dimension, so the amount is
+ * still counted and the entry says the total is *unenforceable* rather than
+ * unknown. Same split `authorize.undeclaredDimensions` makes for the same
+ * reason.
+ */
+async function priceSpec(body) {
+ const flattened = flatten(body)
+ if (!flattened.ok) return { ok: false, error: flattened.error }
+ const { flat } = flattened
+
+ const settings = await settingsDb.byIds(flat.map((s) => s.actionId))
+ const totals = {}
+ const byPhase = new Map()
+ const unpriced = []
+ let priced = 0
+
+ const addTo = (bag, dimension, amount) => {
+ bag[dimension] = (bag[dimension] || 0) + amount
+ }
+
+ for (const step of flat) {
+ if (!byPhase.has(step.phase)) {
+ byPhase.set(step.phase, { phase: step.phase, key: step.phaseKey, steps: 0, draw: {} })
+ }
+ const phase = byPhase.get(step.phase)
+ phase.steps += 1
+
+ const where = { phase: step.phase, seq: step.seq, actionId: step.actionId || null }
+ const action = step.actionId ? registries.eventAction(step.actionId) : null
+ if (!action) {
+ // A step with no action chosen yet is not a problem — it is a form being
+ // filled in — so it is not reported. A step naming an action nothing
+ // registers is, because that is the dormant case and it under-counts.
+ if (step.actionId) {
+ unpriced.push({ ...where, code: 'dormant', message: `no module registers "${step.actionId}"` })
+ }
+ continue
+ }
+
+ const cost = authorize.priceOf(action, step.params)
+ if (cost === null) {
+ unpriced.push({ ...where, code: 'unpriceable', message: `"${action.label}" could not report what it costs` })
+ continue
+ }
+ priced += 1
+ for (const [dimension, amount] of Object.entries(cost)) {
+ addTo(totals, dimension, amount)
+ addTo(phase.draw, dimension, amount)
+ }
+ for (const dimension of authorize.undeclaredDimensions(cost)) {
+ unpriced.push({
+ ...where,
+ code: 'undeclared',
+ message: `spends "${dimension}", which no installed module declares as a budget — this step is refused at dispatch`,
+ })
+ }
+ }
+
+ const caps = authorize.effectiveCaps(
+ flat.map((s) => ({ actionId: s.actionId, params: s.params })),
+ settings,
+ )
+
+ const cost = Object.entries(totals)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([dimension, total]) => {
+ const cap = (caps[dimension] || {}).cap ?? null
+ return {
+ dimension,
+ total,
+ cap,
+ from: (caps[dimension] || {}).from || null,
+ over: cap !== null && total > cap,
+ }
+ })
+
+ return {
+ ok: true,
+ steps: flat.length,
+ priced,
+ cost,
+ // Ordinal order, because that is timeline order and the client draws it
+ // beside each phase. A phase whose steps price to nothing still appears, so
+ // the rollup and the timeline have the same number of rows.
+ phases: [...byPhase.values()].map((p) => ({
+ phase: p.phase,
+ key: p.key,
+ steps: p.steps,
+ draw: Object.entries(p.draw)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([dimension, total]) => ({ dimension, total })),
+ })),
+ unpriced,
+ }
+}
+
+module.exports = { priceSpec }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 9eb4eaf..2dce68f 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -33,6 +33,7 @@ const logDb = require('../../../model/events/eventRunLog.db')
const activity = require('../../../model/activity/activity.model')
const settingsDb = require('../../../model/events/eventActionSettings.db')
const authorize = require('../../../events/authorize')
+const price = require('../../../events/price')
const asId = (raw) => {
const n = Number(raw)
@@ -466,6 +467,44 @@ exports.verify = async (req, res) => {
})
}
+/**
+ * POST /api/v1/admin/events/price — the live cap meter (Phase 13).
+ *
+ * `admin, editor`, exactly as the dry run is and for the same reason: an author
+ * should be able to find out what their plan would cost before asking an admin
+ * to commit the deployment to it.
+ *
+ * **The spec is in the BODY, not looked up by id**, and that is the whole point
+ * of the route. The meter answers a question about the plan in the author's
+ * hands — half-typed, unsaved, and quite possibly not publishable yet — so a
+ * route that read the stored draft would be answering about a spec the author is
+ * no longer looking at.
+ *
+ * It dispatches nothing, unlike `verify`, and it records nothing, unlike a dry
+ * run that passes against a version — which is the stamp §K's unattended-start
+ * gate reads. Those two absences are exactly what make it safe to call while
+ * somebody is still typing.
+ *
+ * A body core cannot make sense of is a `400`; a plan that is over the caps is a
+ * **200**, for the dry run's reason — *"this asks for 45 and you allow 30"* is
+ * an answer, not a failed request.
+ *
+ * Not logged to the activity trail. It is a read that changes nothing and it
+ * fires on a debounce while a form is edited; an audit line per keystroke would
+ * bury the acts that matter under the act of looking.
+ */
+exports.price = async (req, res) => {
+ const result = await price.priceSpec(req.body || {})
+ if (!result.ok) return res.status(400).json({ error: result.error })
+ return res.json({
+ steps: result.steps,
+ priced: result.priced,
+ cost: result.cost,
+ phases: result.phases,
+ unpriced: result.unpriced,
+ })
+}
+
/**
* GET /api/v1/admin/events/actions — the deployment's switchboard.
*
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 20c2b26..29015b1 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -83,6 +83,30 @@ eventsRouter.get(
controller.options,
)
+// ── The live cap meter (Phase 13) ──────────────────────────────────
+//
+// A literal path for the same reason `/actions` is one, and `admin, editor` for
+// the same reason `verify` is: it dispatches nothing and it prices an author's
+// own work.
+//
+// **It takes a spec rather than an id**, which is what separates it from the dry
+// run. A meter has to answer about the form as it stands, and the form is not
+// saved between keystrokes.
+
+eventsRouter.post(
+ '/price',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Price an unsaved spec against the per-run caps, dispatching nothing'
+ // #swagger.description = 'EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { phases: { type: "array", items: { type: "object", properties: { key: { type: "string" }, steps: { type: "array", items: { type: "object", properties: { actionId: { type: "string" }, params: { type: "object", additionalProperties: true } } } } } } } } } } } } */
+ /* #swagger.responses[200] = { description: 'The draw per dimension, the draw per phase, and every step that could not be priced', content: { "application/json": { schema: { type: "object", properties: { steps: { type: "integer" }, priced: { type: "integer" }, cost: { type: "array", items: { type: "object", properties: { dimension: { type: "string" }, total: { type: "integer" }, cap: { type: "integer" }, from: { type: "string" }, over: { type: "boolean" } } } }, phases: { type: "array", items: { type: "object", additionalProperties: true } }, unpriced: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[400] = { description: 'The body is over the spec size limits', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.price,
+)
+
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
//
// A literal path, so it is declared up here with `/catalog` rather than beside
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 3b24427..4e42307 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -4056,6 +4056,138 @@
]
}
},
+ "/api/v1/admin/events/price": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Price an unsaved spec against the per-run caps, dispatching nothing",
+ "description": "EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.",
+ "responses": {
+ "200": {
+ "description": "The draw per dimension, the draw per phase, and every step that could not be priced",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "steps": {
+ "type": "integer"
+ },
+ "priced": {
+ "type": "integer"
+ },
+ "cost": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "dimension": {
+ "type": "string"
+ },
+ "total": {
+ "type": "integer"
+ },
+ "cap": {
+ "type": "integer"
+ },
+ "from": {
+ "type": "string"
+ },
+ "over": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "unpriced": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The body is over the spec size limits",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "actionId": {
+ "type": "string"
+ },
+ "params": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/events/runs": {
"get": {
"tags": [
diff --git a/server/test/eventPrice.test.js b/server/test/eventPrice.test.js
new file mode 100644
index 0000000..d9291d0
--- /dev/null
+++ b/server/test/eventPrice.test.js
@@ -0,0 +1,309 @@
+// ── The live cap meter (EVENTS_PLAN.md Phase 13) ───────────────────────────
+//
+// `events/price.js` answers what a plan would spend without dispatching a thing.
+// Three of the tests below protect a decision rather than a mechanism, and they
+// are the reason this file exists apart from `eventVerify.test.js`:
+//
+// • **A step core cannot price is reported, never counted as free.** All three
+// ways that happens — a dormant action, a `cost()` that broke its own
+// contract, and a dimension nobody declared — make the totals an UNDER-count,
+// and a meter an author trusts that reads lower than what will happen is
+// worse than no meter at all.
+// • **An undeclared dimension is still counted.** It is unenforceable, not
+// unknown: the action really will try to spend it, and the step is refused at
+// dispatch for that reason. Reporting it as costing nothing would hide both
+// facts at once.
+// • **The route dispatches nothing.** An action whose `perform()` would throw
+// prices perfectly well here, which is what makes the meter safe on a
+// debounce — `verify` puts a module and a sidecar behind every call and this
+// deliberately does not.
+//
+// The registry is the real one, staged and applied the way a module does it, for
+// `eventAuthorize.test.js`'s reason: an action that would not register is not one
+// this file has to survive.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const price = require('../src/events/price')
+const settingsDb = require('../src/model/events/eventActionSettings.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const originalSettings = { ...settingsDb }
+
+let settings
+
+beforeEach(() => {
+ registries._reset()
+ settings = new Map()
+ settingsDb.byIds = async (ids) =>
+ new Map([...new Set(ids || [])].filter((i) => settings.has(i)).map((i) => [i, settings.get(i)]))
+})
+
+afterEach(() => {
+ Object.assign(settingsDb, originalSettings)
+ registries._reset()
+})
+
+const action = (id, over = {}) => ({
+ id,
+ label: over.label || id,
+ risk: over.risk || 'notify',
+ reversible: over.reversible || 'none',
+ params: over.params || [],
+ ...(over.cost ? { cost: over.cost } : {}),
+ async perform() {
+ return over.perform ? over.perform() : { ok: true }
+ },
+})
+
+const register = (entries, { owner = 'test', budgets = [] } = {}) => {
+ const api = registries.stage(owner)
+ api.registerEventActions(entries)
+ if (budgets.length) api.registerEventBudgets(budgets.map((id) => ({ id, label: id, unit: 'count' })))
+ registries.apply(api.staged)
+}
+
+const setCaps = (id, caps) => settings.set(id, { action_id: id, enabled: 1, caps })
+
+/** `{ phases: [...] }` out of a compact `[[step, step], [step]]`. */
+const spec = (phases) => ({
+ phases: phases.map((steps, i) => ({
+ key: `phase${i + 1}`,
+ steps: steps.map(([actionId, params = {}]) => ({ actionId, params })),
+ })),
+})
+
+const dimension = (report, id) => report.cost.find((c) => c.dimension === id)
+
+// ── The whole-plan total, which is the number the meter exists to show ──────
+
+test('adds a dimension up across every phase and compares it to the tightest cap', async () => {
+ register(
+ [
+ action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
+ action('test.boss', { cost: () => ({ 'test.creatures': 1 }) }),
+ ],
+ { budgets: ['test.creatures'] },
+ )
+ setCaps('test.spawn', { 'test.creatures': 30 })
+ // The tightest cap wins: two actions spending one dimension have to agree on
+ // one number, and a safety limit settles on the smaller.
+ setCaps('test.boss', { 'test.creatures': 24 })
+
+ const report = await price.priceSpec(
+ spec([
+ [['test.spawn', { count: 15 }]],
+ [['test.spawn', { count: 15 }], ['test.boss', {}]],
+ ]),
+ )
+
+ assert.equal(report.ok, true)
+ assert.equal(report.steps, 3)
+ assert.equal(report.priced, 3)
+ assert.deepEqual(dimension(report, 'test.creatures'), {
+ dimension: 'test.creatures',
+ total: 31,
+ cap: 24,
+ from: 'test.boss',
+ over: true,
+ })
+})
+
+test('a plan inside its cap is not over', async () => {
+ register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) })], {
+ budgets: ['test.creatures'],
+ })
+ setCaps('test.spawn', { 'test.creatures': 30 })
+
+ const report = await price.priceSpec(spec([[['test.spawn', { count: 12 }]]]))
+ assert.equal(dimension(report, 'test.creatures').over, false)
+})
+
+test('a dimension nobody caps comes back uncapped rather than missing', async () => {
+ register([action('test.say', { cost: () => ({ 'test.broadcasts': 1 }) })], { budgets: ['test.broadcasts'] })
+
+ const report = await price.priceSpec(spec([[['test.say', {}]]]))
+ assert.deepEqual(dimension(report, 'test.broadcasts'), {
+ dimension: 'test.broadcasts',
+ total: 1,
+ cap: null,
+ from: null,
+ over: false,
+ })
+})
+
+// ── The per-phase draw the timeline renders ────────────────────────────────
+
+test('reports the draw per phase, in timeline order, including a phase that spends nothing', async () => {
+ register(
+ [
+ action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
+ action('test.wait'),
+ ],
+ { budgets: ['test.creatures'] },
+ )
+
+ const report = await price.priceSpec(
+ spec([
+ [['test.spawn', { count: 8 }]],
+ [['test.wait', {}]],
+ [['test.spawn', { count: 4 }], ['test.spawn', { count: 2 }]],
+ ]),
+ )
+
+ assert.deepEqual(
+ report.phases.map((p) => ({ phase: p.phase, key: p.key, steps: p.steps, draw: p.draw })),
+ [
+ { phase: 0, key: 'phase1', steps: 1, draw: [{ dimension: 'test.creatures', total: 8 }] },
+ // A phase whose steps cost nothing still appears, so the rollup and the
+ // timeline have the same number of rows.
+ { phase: 1, key: 'phase2', steps: 1, draw: [] },
+ { phase: 2, key: 'phase3', steps: 2, draw: [{ dimension: 'test.creatures', total: 6 }] },
+ ],
+ )
+})
+
+test('a phase is addressed by its ordinal, so an unnamed one still meters', async () => {
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 3 }) })], { budgets: ['test.creatures'] })
+
+ // The key a half-typed phase carries may not validate yet. A meter that could
+ // only address a phase whose key is already legal would go blank exactly while
+ // somebody is naming it.
+ const report = await price.priceSpec({ phases: [{ steps: [{ actionId: 'test.spawn', params: {} }] }] })
+ assert.equal(report.phases[0].phase, 0)
+ assert.equal(report.phases[0].key, null)
+ assert.equal(dimension(report, 'test.creatures').total, 3)
+})
+
+// ── The three ways a step cannot be priced ─────────────────────────────────
+
+test('a step naming an action nothing registers is reported, not silently free', async () => {
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
+
+ const report = await price.priceSpec(spec([[['test.spawn', {}], ['uo.creature.spawn', {}]]]))
+
+ assert.equal(report.steps, 2)
+ assert.equal(report.priced, 1)
+ assert.deepEqual(report.unpriced, [
+ {
+ phase: 0,
+ seq: 1,
+ actionId: 'uo.creature.spawn',
+ code: 'dormant',
+ message: 'no module registers "uo.creature.spawn"',
+ },
+ ])
+})
+
+test('a step with no action chosen yet is not a problem', async () => {
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
+
+ // A form being filled in, not a plan with a hole in it. Reporting it would put
+ // a red line on the screen for every step the moment it is added.
+ const report = await price.priceSpec(spec([[['test.spawn', {}], ['', {}]]]))
+ assert.deepEqual(report.unpriced, [])
+ assert.equal(report.steps, 2)
+ assert.equal(report.priced, 1)
+})
+
+test('an action whose cost() breaks its own contract is unpriceable, not free', async () => {
+ register(
+ [
+ action('test.broken', {
+ label: 'Broken',
+ cost: () => {
+ throw new Error('nope')
+ },
+ }),
+ ],
+ { budgets: [] },
+ )
+
+ const report = await price.priceSpec(spec([[['test.broken', {}]]]))
+ assert.equal(report.priced, 0)
+ assert.equal(report.unpriced.length, 1)
+ assert.equal(report.unpriced[0].code, 'unpriceable')
+ assert.match(report.unpriced[0].message, /could not report what it costs/)
+})
+
+test('an undeclared dimension is COUNTED and reported as unenforceable', async () => {
+ // The split `authorize.undeclaredDimensions` makes, for the same reason: the
+ // action really will try to spend it — the step is refused at dispatch for
+ // exactly this — so the amount is true and the enforcement is what is missing.
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 9 }) })], { budgets: [] })
+
+ const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
+ assert.equal(dimension(report, 'test.creatures').total, 9)
+ assert.equal(report.priced, 1)
+ assert.equal(report.unpriced[0].code, 'undeclared')
+ assert.match(report.unpriced[0].message, /refused at dispatch/)
+})
+
+// ── What makes it safe to call while somebody types ────────────────────────
+
+test('prices without dispatching: an action whose perform() throws still meters', async () => {
+ register(
+ [
+ action('test.spawn', {
+ cost: () => ({ 'test.creatures': 7 }),
+ perform: () => {
+ throw new Error('the shard is down')
+ },
+ }),
+ ],
+ { budgets: ['test.creatures'] },
+ )
+
+ const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
+ assert.equal(dimension(report, 'test.creatures').total, 7)
+ assert.deepEqual(report.unpriced, [])
+})
+
+test('an empty plan prices to nothing rather than failing', async () => {
+ const report = await price.priceSpec({})
+ assert.deepEqual(report, { ok: true, steps: 0, priced: 0, cost: [], phases: [], unpriced: [] })
+})
+
+// ── The paste guard, which is the spec's own and not a number invented here ──
+
+test('refuses a body over the spec size limits', async () => {
+ const spawn = { actionId: 'test.spawn', params: {} }
+ const tooManyPhases = { phases: Array.from({ length: 41 }, (_, i) => ({ key: `p${i}`, steps: [] })) }
+ assert.deepEqual(await price.priceSpec(tooManyPhases), { ok: false, error: 'at most 40 phases' })
+
+ const tooManySteps = { phases: [{ key: 'p', steps: Array.from({ length: 101 }, () => spawn) }] }
+ assert.deepEqual(await price.priceSpec(tooManySteps), {
+ ok: false,
+ error: 'at most 100 steps in one phase',
+ })
+
+ // 40 x 100 is over MAX_STEPS while breaking neither of the two bounds above.
+ const tooManyOverall = {
+ phases: Array.from({ length: 40 }, (_, i) => ({
+ key: `p${i}`,
+ steps: Array.from({ length: 100 }, () => spawn),
+ })),
+ }
+ assert.deepEqual(await price.priceSpec(tooManyOverall), {
+ ok: false,
+ error: 'at most 500 steps in one definition',
+ })
+})
+
+test('a step whose params are not an object is priced as no params rather than throwing', async () => {
+ register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 1 }) })], {
+ budgets: ['test.creatures'],
+ })
+
+ const report = await price.priceSpec({
+ phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', params: ['not', 'an', 'object'] }] }],
+ })
+ assert.equal(dimension(report, 'test.creatures').total, 1)
+})
diff --git a/server/test/eventsRoles.test.js b/server/test/eventsRoles.test.js
index d0d64f5..c3c9411 100644
--- a/server/test/eventsRoles.test.js
+++ b/server/test/eventsRoles.test.js
@@ -24,6 +24,10 @@
// both buttons, would behave badly in exactly the case the moderator role
// exists for.
//
+// Phase 13 adds the meter beside `verify`, and it is the one route here that
+// neither dispatches nor records — which is what makes it safe to call on a
+// debounce while a form is edited.
+//
// Phase 6 adds the switchboard to the `admin` column — §K puts it in the same row
// as the world-changing actions it governs — and `verify` to the `admin, editor`
// one, because a dry run dispatches nothing and the author who wrote the
@@ -154,6 +158,9 @@ const SURFACE = [
['DELETE', '/events/series/1', ['admin', 'editor']],
// Phase 6. A dry run dispatches nothing and changes nothing.
['POST', '/events/1/verify', ['admin', 'editor']],
+ // Phase 13's meter, in the same column and for a stronger version of the
+ // same reason: it dispatches nothing AND records nothing.
+ ['POST', '/events/price', ['admin', 'editor']],
// Committing the deployment: admin only (§N2).
['POST', '/events/1/publish', ['admin']],
@@ -214,7 +221,9 @@ test('start and stop are NOT the same gate, and that is the point', async () =>
test('an editor may price an event but not publish or start it', async () => {
// Phase 6's addition to the same shape: the author who wrote the definition can
// find out what it would cost before asking an admin to commit the deployment.
+ // Phase 13 gave the same author the meter, on the same argument.
assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false)
+ assert.equal(await forbidden('POST', '/events/price', 'editor'), false)
assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true)
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
})
--
2.49.1
From 1667e636bd5d3b32963bcf6ffdb4932e3399d5be Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 8 Sep 2026 06:18:38 -0500
Subject: [PATCH 14/18] feat(events): the public calendar, event pages and
participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.
Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.
`listed` is announcement, not permission. 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 Phase 13'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 public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.
The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
---
client/src/App.jsx | 20 +
client/src/api/client.js | 30 +
client/src/components/SiteHeader.jsx | 1 +
client/src/lib/eventAuthoring.js | 10 +
client/src/lib/eventCalendar.js | 99 ++
client/src/routes/admin/views/EventEditor.jsx | 15 +-
client/src/routes/player/PlayerEvents.jsx | 84 +
.../src/routes/player/PlayerPortalLayout.jsx | 6 +
client/src/routes/public/EventPage.jsx | 190 +++
client/src/routes/public/EventSeries.jsx | 78 +
client/src/routes/public/Events.jsx | 131 ++
client/test/apiClient.test.js | 37 +
client/test/eventAuthoring.test.js | 23 +
client/test/eventCalendar.test.js | 89 +
server/db/schema.sql | 19 +
server/engagement-triggers.json | 54 +-
server/routes.guards.json | 33 +
server/routes.manifest.json | 16 +
server/src/config/coreTriggers.js | 78 +-
server/src/config/version.js | 21 +
server/src/engagement/templateSeeds.js | 19 +-
server/src/events/announce.js | 22 +
.../src/model/events/eventDefinitions.db.js | 52 +-
.../model/events/eventDefinitions.model.js | 10 +
server/src/model/events/eventPublic.model.js | 409 +++++
.../model/events/eventRunParticipants.db.js | 48 +-
server/src/model/events/eventRuns.db.js | 47 +-
server/src/model/events/eventSeries.db.js | 7 +-
.../src/router/v1/admin/events.controller.js | 4 +
.../src/router/v1/player/events.controller.js | 29 +
server/src/router/v1/player/events.router.js | 39 +
server/src/router/v1/player/index.js | 4 +
.../src/router/v1/public/events.controller.js | 68 +
server/src/router/v1/public/events.router.js | 60 +
server/src/router/v1/public/index.js | 5 +
server/swagger/swagger-output.json | 1515 +++++++++++++++++
server/swagger/swagger.js | 191 +++
server/test/eventAnnounce.test.js | 29 +-
server/test/eventPublic.test.js | 417 +++++
39 files changed, 3964 insertions(+), 45 deletions(-)
create mode 100644 client/src/lib/eventCalendar.js
create mode 100644 client/src/routes/player/PlayerEvents.jsx
create mode 100644 client/src/routes/public/EventPage.jsx
create mode 100644 client/src/routes/public/EventSeries.jsx
create mode 100644 client/src/routes/public/Events.jsx
create mode 100644 client/test/eventCalendar.test.js
create mode 100644 server/src/model/events/eventPublic.model.js
create mode 100644 server/src/router/v1/player/events.controller.js
create mode 100644 server/src/router/v1/player/events.router.js
create mode 100644 server/src/router/v1/public/events.controller.js
create mode 100644 server/src/router/v1/public/events.router.js
create mode 100644 server/test/eventPublic.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 6a6bb69..74db2f0 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
+import Events from './routes/public/Events.jsx'
+import EventPage from './routes/public/EventPage.jsx'
+import EventSeries from './routes/public/EventSeries.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -74,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
+import PlayerEvents from './routes/player/PlayerEvents.jsx'
export default function App() {
return (
@@ -107,6 +111,17 @@ export default function App() {
} />
} />
} />
+ {/* Events (Phase 14a). `series/:slug` is declared before `:slug`
+ although it could not be shadowed by it — two segments against
+ one. It stays above because the ranking surprise this feature
+ has already shipped once was exactly here: a static segment
+ outranks a dynamic one whatever the source order, which is what
+ made `/admin/events/new` unreachable from Phase 6 to Phase 13.
+ Nothing static shares a segment with `:slug`, so nothing here
+ repeats it. */}
+ } />
+ } />
+ } />
} />
} />
} />
@@ -290,6 +305,11 @@ export default function App() {
} />
} />
} />
+ {/* Participation history (Phase 14a). Under /account rather than
+ /player because it is role-agnostic self-service: staff are a
+ superset of players and an admin reading their own attendance
+ is as ordinary as anyone else doing it. */}
+ } />
{/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word
diff --git a/client/src/api/client.js b/client/src/api/client.js
index e3d486a..92b4e1c 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -256,6 +256,24 @@ export const api = {
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
+ // ----- Events (EVENTS.md § API surface, Phase 14a) -----
+ //
+ // The anonymous surface. `from`/`to` are optional — the server defaults to now
+ // through a month out, so the calendar's first render need not compute a window
+ // before it can ask for anything.
+ publicEvents: ({ from, to, seriesId } = {}) => {
+ const qs = new URLSearchParams()
+ if (from) qs.set('from', from)
+ if (to) qs.set('to', to)
+ if (seriesId) qs.set('seriesId', String(seriesId))
+ return req(`/public/events${withQs(qs.toString())}`)
+ },
+ // `run` is what an announcement's link carries, so a mail about last Friday's
+ // occurrence opens last Friday's results rather than next Friday's.
+ publicEvent: (slug, run = null) =>
+ req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
+ publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
+
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -708,6 +726,18 @@ export const api = {
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
+
+ // ----- event participation (Phase 14a) -----
+ //
+ // Self-scoped on the session and nothing else — there is no id to pass.
+ // `before` is a keyset cursor (the last entry's `id`), not an offset: the
+ // list gains a row every time the reader attends something.
+ eventHistory: ({ limit, before } = {}) => {
+ const qs = new URLSearchParams()
+ if (limit) qs.set('limit', String(limit))
+ if (before) qs.set('before', String(before))
+ return req(`/player/events/history${withQs(qs.toString())}`)
+ },
},
}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 21eee19..df088e4 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -29,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx'
export const NAV = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
+ { label: 'Events', to: '/site/events' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 8a089e1..87c9e32 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -232,6 +232,14 @@ export function formFromDefinition(event) {
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
+ // Whether the public calendar announces it (Phase 14a). `?? true` rather
+ // than `|| true`: a definition an operator has deliberately unlisted sends
+ // `false`, and `||` would quietly re-list it on the next save.
+ listed: event?.listed ?? true,
+ // Whether the public calendar announces it (Phase 14a). `?? true` rather
+ // than `|| true`: a definition an operator has deliberately unlisted sends
+ // `false`, and `||` would quietly re-list it on the next save.
+ listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
@@ -351,6 +359,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
+ listed: Boolean(form.listed),
+ listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
},
}
diff --git a/client/src/lib/eventCalendar.js b/client/src/lib/eventCalendar.js
new file mode 100644
index 0000000..fafff21
--- /dev/null
+++ b/client/src/lib/eventCalendar.js
@@ -0,0 +1,99 @@
+// Rendering an event's instant, shared by the public event screens.
+//
+// **The split these two functions make is EVENTS.md §I's, and it is the one
+// thing about event times that is easy to get wrong.** The server returns UTC
+// instants and never guesses the reader's zone. The client places them:
+//
+// • the DAY an entry is filed under is the reader's own — "what is on this
+// month" is a question about the month the person reading is living in;
+// • the TIME beside it is always the EVENT's zone, carried on the entry —
+// because every listing this feature replaces is written in the shard's
+// local zone, and "8pm" means the shard's evening to everyone reading it.
+//
+// Rendering the time in the reader's zone instead would be defensible and is
+// wrong here: a player in Berlin told an American shard's event is at "02:00"
+// has been told something true and useless, and told it in a way that makes the
+// shard's own announcement look like a mistake.
+
+/** The event's own wall clock, with the zone named so it misreads as nothing. */
+export function eventTime(instant, timezone) {
+ try {
+ const time = new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).format(new Date(instant))
+ return `${time} ${shortZone(timezone)}`
+ } catch {
+ // An unknown IANA name throws rather than falling back, and an event whose
+ // timezone column holds a typo must still render. UTC off the instant is the
+ // honest answer when the zone cannot be honoured.
+ return `${new Date(instant).toISOString().slice(11, 16)} UTC`
+ }
+}
+
+/** The zone as a reader recognises it: `America/New_York` → `New York`. */
+function shortZone(timezone) {
+ if (!timezone) return 'UTC'
+ const tail = String(timezone).split('/').pop()
+ return tail.replace(/_/g, ' ')
+}
+
+/** The reader's own day, for the heading an entry is filed under. */
+export function readerDayLabel(instant) {
+ const d = new Date(instant)
+ if (Number.isNaN(d.getTime())) return ''
+ return new Intl.DateTimeFormat(undefined, {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
+ }).format(d)
+}
+
+/** The event's own day and time together, for a page that shows one occurrence. */
+export function eventDateTime(instant, timezone) {
+ const d = new Date(instant)
+ if (Number.isNaN(d.getTime())) return ''
+ try {
+ return `${new Intl.DateTimeFormat(undefined, {
+ timeZone: timezone,
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).format(d)} ${shortZone(timezone)}`
+ } catch {
+ return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
+ }
+}
+
+// The word beside an entry, for the four public statuses.
+//
+// **`cancelled` needs the instant, and that is the whole reason this is a
+// function rather than a lookup table.** The server publishes `failed` and
+// `missed` as `cancelled` too — to a visitor those three are one event, and the
+// difference between them is about the deployment — but the three do not share
+// one English sentence. "Did not happen" is right for a past occurrence and a
+// plain falsehood for a future one, and a run four days out that an operator has
+// called off is exactly the common case: the calendar was saying *did not
+// happen* about next Friday.
+//
+// So the tense follows the clock, not the status. A future call-off reads
+// **Cancelled**; a past one reads **Did not happen**, which is also the honest
+// word for the failed and missed runs folded in with it.
+const WORDS = {
+ live: 'Happening now',
+ scheduled: 'Scheduled',
+ completed: 'Finished',
+}
+
+export function statusWord(status, scheduledFor, now = Date.now()) {
+ if (WORDS[status]) return WORDS[status]
+ if (status !== 'cancelled') return status
+ const at = new Date(scheduledFor).getTime()
+ return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
+}
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index 3ce4ea2..d19eac2 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -1164,11 +1164,24 @@ export default function EventEditor() {
Storyline
diff --git a/client/src/routes/player/PlayerEvents.jsx b/client/src/routes/player/PlayerEvents.jsx
new file mode 100644
index 0000000..1fe3d93
--- /dev/null
+++ b/client/src/routes/player/PlayerEvents.jsx
@@ -0,0 +1,84 @@
+// This account's event participation (EVENTS.md §J, Phase 14a).
+//
+// **The screen's one real design decision is what an unranked row says.** A run
+// whose participants were collected but whose results have not been published
+// has a score and no rank, and that is a real state rather than an error — it is
+// the same state the admin run console has shown since Phase 10. Rendering "—"
+// with nothing explaining it would read as a bug; the row says "not published",
+// which is a fact about the event rather than about the reader.
+//
+// The list is keyset-paged on the participation row's own id, not offset-paged:
+// it gains a row every time the reader attends something.
+
+import { useCallback, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { api } from '../../api/client.js'
+import { eventDateTime } from '../../lib/eventCalendar.js'
+
+const PAGE = 25
+
+export default function PlayerEvents() {
+ const [pages, setPages] = useState([])
+ const [more, setMore] = useState(false)
+ const [loadingMore, setLoadingMore] = useState(false)
+
+ const load = useCallback(async () => {
+ const result = await api.player.eventHistory({ limit: PAGE })
+ setPages([result.entries || []])
+ setMore((result.entries || []).length === PAGE)
+ return result
+ }, [])
+ const { loading, error } = useAsync(load)
+
+ const entries = pages.flat()
+
+ const loadMore = async () => {
+ const last = entries[entries.length - 1]
+ if (!last) return
+ setLoadingMore(true)
+ try {
+ const result = await api.player.eventHistory({ limit: PAGE, before: last.id })
+ setPages((p) => [...p, result.entries || []])
+ setMore((result.entries || []).length === PAGE)
+ } finally {
+ setLoadingMore(false)
+ }
+ }
+
+ if (error) return
+ if (loading) return
+ if (entries.length === 0) {
+ return You have not taken part in an event yet.
+ }
+
+ return (
+
+ )
+}
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
index 22e2a97..1e1e949 100644
--- a/client/src/routes/player/PlayerPortalLayout.jsx
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -39,6 +39,11 @@ const IconShield = () =>
// The settings row's own icon: a bell would make the two rows read as the same
// destination twice, which is exactly the confusion the split was meant to end.
+// Participation history (Phase 14a). A calendar rather than a trophy: the row
+// is every event this account attended, ranked or not, and most of them will
+// never have a result published against them at all.
+const IconCalendar = () =>
+
const IconBellGear = () =>
// Exported because Admin -> Navigation edits this list. It stays declared here;
@@ -51,6 +56,7 @@ const IconBellGear = () =>
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
+ { to: '/account/events', label: 'Events', icon: IconCalendar },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
diff --git a/client/src/routes/public/EventPage.jsx b/client/src/routes/public/EventPage.jsx
new file mode 100644
index 0000000..a401b9c
--- /dev/null
+++ b/client/src/routes/public/EventPage.jsx
@@ -0,0 +1,190 @@
+// One event's public page (EVENTS.md § API surface, Phase 14a).
+//
+// The storyline, its arc, what is live, what is next, what happened recently,
+// and a results table once an occurrence has published one.
+//
+// **`?run=` is read from the URL and passed straight through**, because that is
+// what an announcement's link carries. The page lives at the definition's slug —
+// one stable address, so a link posted in Discord survives a retitle — and the
+// occurrence has to be in the query string or a mail about last Friday's
+// invasion would open next Friday's.
+//
+// **The error is checked before the form.** Phase 13 found the inverse of this
+// on the admin editor: `if (loading || !form) return ` above the error
+// branch left a failed load spinning for ever with nothing on screen naming the
+// problem. Order matters, and the order is error first.
+
+import { useParams, useSearchParams, Link } from 'react-router-dom'
+import PublicLayout from '../../components/PublicLayout.jsx'
+import PageHeader from '../../components/PageHeader.jsx'
+import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { api } from '../../api/client.js'
+import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
+
+export default function EventPage() {
+ const { slug } = useParams()
+ const [params] = useSearchParams()
+ const run = params.get('run')
+ const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
+
+ if (error) {
+ return (
+
+
+ )}
+
+ {/* The one fact a visitor came for, before the storyline rather than
+ after it: whether it is happening now, and if not, when it next is. */}
+
+ {event.live ? (
+ <>
+
+ Happening now
+
+
+ {/* The phase LABEL, and only while it is live. The plan behind
+ the event is never published. */}
+ {event.current.phase || 'Under way'}
+
+ {/* A module supplies a display name in `meta` or it does
+ not; the member key is never published, so there is
+ genuinely nothing else to render. */}
+
{p.name || Unnamed}
+
{p.score}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+ {!headline && event.past.length === 0 && (
+ This event has not been scheduled yet.
+ )}
+
+ {eventDateTime(o.scheduledFor, o.timezone || timezone)}
+ {statusWord(o.status, o.scheduledFor)}
+ {/* Only a past occurrence gets its own link, and only when it has
+ results: on any other, `?run=` would change nothing a reader
+ could see. */}
+ {past && o.resultsPublishedAt && (
+
+ Results
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/routes/public/EventSeries.jsx b/client/src/routes/public/EventSeries.jsx
new file mode 100644
index 0000000..9caad82
--- /dev/null
+++ b/client/src/routes/public/EventSeries.jsx
@@ -0,0 +1,78 @@
+// One arc (EVENTS.md §I, Phase 14a).
+//
+// **The arc is the thing the tooling this replaces could not express at all.**
+// A WordPress calendar plugin has no series field, so "Royal Spy Mission → Risky
+// Partner → Message From the Void" existed only in a GM's head and in whatever
+// the forum post said. This page is that continuity, in the order an editor
+// arranged it — which is why the events are numbered rather than dated: an arc
+// has an order, and its parts may be months apart or run out of sequence.
+
+import { useParams, Link } from 'react-router-dom'
+import PublicLayout from '../../components/PublicLayout.jsx'
+import PageHeader from '../../components/PageHeader.jsx'
+import { Loading, ErrorState } from '../../components/PageState.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { api } from '../../api/client.js'
+
+export default function EventSeries() {
+ const { slug } = useParams()
+ const { loading, error, data } = useAsync(() => api.publicEventSeries(slug), [slug])
+
+ // Error first, then loading — the order Phase 13 had to fix on the admin
+ // editor, where a failed load sat behind a spinner that never stopped.
+ if (error) {
+ return (
+
+
+
+ )
+}
diff --git a/client/src/routes/public/Events.jsx b/client/src/routes/public/Events.jsx
new file mode 100644
index 0000000..f5e6d3c
--- /dev/null
+++ b/client/src/routes/public/Events.jsx
@@ -0,0 +1,131 @@
+// The public event calendar (EVENTS.md §I, Phase 14a).
+//
+// **A list, not a month grid.** The admin calendar draws a grid because an
+// operator's question is "what does this month look like" — coverage, clashes,
+// the gap on the third weekend. A visitor's question is "what is on, and when is
+// the next one", which a chronological list answers in one glance and a grid
+// answers by making them count squares. Same data, different question.
+//
+// **A projection is drawn differently from a run, and the reason is the
+// operator's reason one tier along.** Past the materialisation horizon there is
+// no row: nothing is committed to, nothing can be cancelled, and a forecast
+// rendered identically to a booking would be the page promising something the
+// server has not. It is dashed and labelled "expected".
+//
+// The date heading is the READER's day and the time beside each entry is the
+// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
+// to everyone reading it, but "this month" is the month the reader is living in.
+
+import { Link } from 'react-router-dom'
+import PublicLayout from '../../components/PublicLayout.jsx'
+import PageHeader from '../../components/PageHeader.jsx'
+import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
+import { useAsync } from '../../lib/useAsync.js'
+import { api } from '../../api/client.js'
+import { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
+
+export default function Events() {
+ const { loading, error, data } = useAsync(() => api.publicEvents())
+ const entries = data?.entries || []
+
+ // Grouped by the reader's own day, in order. The server already sorted by
+ // instant, so this preserves that order rather than re-sorting.
+ const days = []
+ for (const entry of entries) {
+ const label = readerDayLabel(entry.scheduledFor)
+ const last = days[days.length - 1]
+ if (last && last.label === label) last.entries.push(entry)
+ else days.push({ label, entries: [entry] })
+ }
+
+ return (
+
+
+
+
+ {loading && }
+ {error && }
+ {!loading && !error && entries.length === 0 && (
+ Nothing on the calendar just yet — check back soon.
+ )}
+ {days.map((day) => (
+
+ )
+
+ // A projection has no page of its own worth linking to any differently — the
+ // event page IS the definition's — so both link to the same place. It is the
+ // OCCURRENCE that does not exist yet, not the event.
+ return {body}
+}
diff --git a/client/test/apiClient.test.js b/client/test/apiClient.test.js
index 4942133..c401a26 100644
--- a/client/test/apiClient.test.js
+++ b/client/test/apiClient.test.js
@@ -252,3 +252,40 @@ test('a Team slug is URL-encoded on every forum path', async () => {
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
})
+
+
+// ── Public events (Phase 14a) ───────────────────────────────────────────
+//
+// The one shape worth pinning is `?run=`: it is what an announcement's link
+// carries, and a client that dropped it would make a mail about last Friday's
+// occurrence open next Friday's.
+
+test('the public calendar asks for no window at all by default', async () => {
+ willReply({ body: { entries: [] } })
+ await api.publicEvents()
+ // The server defaults to now through a month out, so the first render need
+ // not compute two ISO instants before it can ask for anything.
+ assert.equal(calls[0].url, '/api/v1/public/events')
+})
+
+test('an event page carries the run when one was named, and not when it was not', async () => {
+ willReply({ body: { ok: true } })
+ await api.publicEvent('the-yew-invasion')
+ assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
+
+ willReply({ body: { ok: true } })
+ await api.publicEvent('the-yew-invasion', 3692)
+ assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
+})
+
+test('an event slug is URL-encoded on every public path', async () => {
+ willReply({ body: { ok: true } })
+ await api.publicEventSeries('a b/c')
+ assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
+})
+
+test('participation history takes a keyset cursor, never an offset', async () => {
+ willReply({ body: { entries: [] } })
+ await api.player.eventHistory({ limit: 25, before: 900 })
+ assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
+})
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
index 04dc984..1c86248 100644
--- a/client/test/eventAuthoring.test.js
+++ b/client/test/eventAuthoring.test.js
@@ -252,6 +252,29 @@ test('the form round-trips a definition without losing a step', () => {
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
})
+test('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
+ // The trap this guards is `||` where `??` is meant. A definition an operator
+ // deliberately unlisted sends `listed: false`, and `event?.listed || true`
+ // would put it back on the public calendar on the author's next save — a
+ // surprise event announced by a typo fix.
+ const unlisted = payloadFromForm(
+ formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
+ )
+ assert.equal(unlisted.payload.listed, false)
+
+ const listed = payloadFromForm(
+ formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
+ )
+ assert.equal(listed.payload.listed, true)
+})
+
+test('a new definition defaults to listed', () => {
+ // The column's own default, and the ordinary case: unlisting is the
+ // deliberate act, not listing.
+ const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
+ assert.equal(fresh.payload.listed, true)
+})
+
test('an unchosen onFailure is omitted rather than invented', () => {
// The server defaults it from the action's risk class, which is the whole
// reason `risk` is required at registration. A form that posted a value would
diff --git a/client/test/eventCalendar.test.js b/client/test/eventCalendar.test.js
new file mode 100644
index 0000000..a38f6b7
--- /dev/null
+++ b/client/test/eventCalendar.test.js
@@ -0,0 +1,89 @@
+// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
+//
+// One property matters here and it is EVENTS.md §I's: **the time beside an
+// entry is the EVENT's zone, the day it is filed under is the READER's.** A
+// helper that quietly rendered both in the reader's zone would pass any test
+// that only ever looked at one of them, and would put an American shard's 8pm
+// event at "02:00" for a player in Berlin — true, useless, and looking like the
+// shard's own announcement was wrong.
+
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
+
+// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
+// whose DATE differs between the two zones, which is what makes the split
+// observable at all.
+const INSTANT = '2026-09-12T00:00:00.000Z'
+
+test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
+ assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
+ assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
+ assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
+})
+
+test('the zone is named in a form a reader recognises', () => {
+ // `America/New_York` is a database identifier, not something to show a player.
+ assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
+})
+
+test('an unknown zone falls back to UTC rather than throwing', () => {
+ // `Intl` rejects an unknown identifier, and an event whose timezone column
+ // holds a typo must still render.
+ assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
+ assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
+})
+
+test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
+ assert.equal(readerDayLabel('not a date'), '')
+ assert.equal(eventDateTime('not a date', 'UTC'), '')
+})
+
+test('the day label is the reader’s own day, whatever the event’s zone', () => {
+ // Two entries at the same instant in different event zones are filed under one
+ // heading, which is what makes a chronological list group correctly.
+ assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
+ const label = readerDayLabel(INSTANT)
+ assert.ok(label.length > 0)
+ // The instant's UTC date is the 12th and New York's is the 11th; the label
+ // must not carry a zone at all, because it is neither of theirs.
+ assert.equal(/UTC|New York/.test(label), false)
+})
+
+test('eventDateTime carries the day and the zone together', () => {
+ const text = eventDateTime(INSTANT, 'America/New_York')
+ assert.match(text, /New York$/)
+ assert.match(text, /20:00/)
+})
+
+// ── The status word ────────────────────────────────────────────────────────
+//
+// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
+// run four days out that an operator had cancelled. The server publishes
+// `failed` and `missed` as `cancelled` too — to a visitor the three are one
+// event — but they do not share one English sentence, so the tense follows the
+// clock rather than the status.
+
+const NOW = Date.parse('2026-09-08T12:00:00Z')
+
+test('a cancelled occurrence in the future reads "Cancelled"', () => {
+ assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
+})
+
+test('a cancelled occurrence in the past reads "Did not happen"', () => {
+ // Which is also the honest word for the failed and missed runs folded into
+ // `cancelled` on the way out.
+ assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
+})
+
+test('the other three words do not depend on the clock at all', () => {
+ for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
+ assert.equal(statusWord('live', at, NOW), 'Happening now')
+ assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
+ assert.equal(statusWord('completed', at, NOW), 'Finished')
+ }
+})
+
+test('an unreadable instant falls to the past-tense word rather than throwing', () => {
+ assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
+})
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 805f4bb..2ef8de3 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -2598,6 +2598,25 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
+-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
+--
+-- **Not a second answer to the question `state` answers**, which is the trap the
+-- `findSchedulable` comment in eventDefinitions.db.js warns about: `state` says
+-- whether an event is SCHEDULABLE, and this 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 this column a
+-- surprise invasion would have to be advertised a fortnight in advance in order
+-- to be allowed to happen.
+--
+-- Default 1, so every definition that exists 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:
+-- the admin calendar is the operational view, and an event nobody outside can
+-- see is still an event the team is running.
+ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS listed TINYINT(1) NOT NULL DEFAULT 1;
+
-- ── Integrations: participants, results and the run's announcements
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index 4c0ab06..a72784a 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -11,7 +11,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
- "version": 1,
+ "version": 2,
"variables": [
{
"name": "runId",
@@ -54,6 +54,13 @@
"required": true,
"example": 4,
"description": "How many phases the pinned version has in total."
+ },
+ {
+ "name": "eventUrl",
+ "type": "url",
+ "required": false,
+ "example": "/site/events/the-yew-invasion?run=3692",
+ "description": "The public page for this occurrence."
}
]
},
@@ -66,7 +73,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
- "version": 1,
+ "version": 2,
"variables": [
{
"name": "runId",
@@ -88,6 +95,13 @@
"required": false,
"example": "The shard is down for an emergency patch.",
"description": "What the staff member gave as the reason, when they gave one."
+ },
+ {
+ "name": "eventUrl",
+ "type": "url",
+ "required": false,
+ "example": "/site/events/the-yew-invasion?run=3692",
+ "description": "The public page for this occurrence."
}
]
},
@@ -100,7 +114,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
- "version": 1,
+ "version": 2,
"variables": [
{
"name": "runId",
@@ -136,6 +150,13 @@
"required": true,
"example": 95,
"description": "How long the run took, start to end, in whole minutes."
+ },
+ {
+ "name": "eventUrl",
+ "type": "url",
+ "required": false,
+ "example": "/site/events/the-yew-invasion?run=3692",
+ "description": "The public page for this occurrence."
}
]
},
@@ -148,7 +169,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
- "version": 1,
+ "version": 2,
"variables": [
{
"name": "runId",
@@ -163,6 +184,13 @@
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
+ },
+ {
+ "name": "eventUrl",
+ "type": "url",
+ "required": false,
+ "example": "/site/events/the-yew-invasion?run=3692",
+ "description": "The public page for this occurrence."
}
]
},
@@ -223,7 +251,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
- "version": 1,
+ "version": 2,
"variables": [
{
"name": "runId",
@@ -273,6 +301,13 @@
"required": false,
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
"description": "The start time written out in the shard-local zone, for a mail to read."
+ },
+ {
+ "name": "eventUrl",
+ "type": "url",
+ "required": false,
+ "example": "/site/events/the-yew-invasion?run=3692",
+ "description": "The public page for this occurrence."
}
]
},
@@ -285,7 +320,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
- "version": 1,
+ "version": 2,
"variables": [
{
"name": "runId",
@@ -335,6 +370,13 @@
"required": false,
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
"description": "The start time written out in the shard-local zone, for a mail to read."
+ },
+ {
+ "name": "eventUrl",
+ "type": "url",
+ "required": false,
+ "example": "/site/events/the-yew-invasion?run=3692",
+ "description": "The public page for this occurrence."
}
]
},
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 3e83c5d..5fcb829 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -2267,6 +2267,15 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/player/events/history",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/player/teams",
@@ -2444,6 +2453,30 @@
"handlers": 1,
"gates": []
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/events",
+ "handlers": 2,
+ "gates": [
+ "siteMode"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/public/events/:slug",
+ "handlers": 2,
+ "gates": [
+ "siteMode"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/public/events/series/:slug",
+ "handlers": 2,
+ "gates": [
+ "siteMode"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/public/modules",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index df698f0..7119f45 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -933,6 +933,10 @@
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/player/events/history"
+ },
{
"method": "GET",
"path": "/api/v1/player/teams"
@@ -1005,6 +1009,18 @@
"method": "POST",
"path": "/api/v1/public/engagement/unsubscribe/:token"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/events"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/public/events/:slug"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/public/events/series/:slug"
+ },
{
"method": "GET",
"path": "/api/v1/public/modules"
diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js
index eba59d7..414ccb6 100644
--- a/server/src/config/coreTriggers.js
+++ b/server/src/config/coreTriggers.js
@@ -185,15 +185,22 @@ const TRIGGERS = [
// runs of the same definition the ids differ, so a weekly event is not
// throttled by last week's.
//
- // **None of the six public ones declares a `url` variable, deliberately.**
- // There is no public event page until Phase 14 — `App.jsx` mounts nothing
- // under `/site/events` — and `news.post` has already paid for this mistake
- // once: its `postUrl` example named `/news/`, a path that does not
- // exist, and the template editor previewed a link that was dead in every mail
- // it sent. A variable added in Phase 14 alongside the page it points at is a
- // version bump; a variable shipped now is a 404 in an operator's first
- // announcement. `run.failed` is the exception because its destination exists
- // today: `/admin/events/runs/:runId` is a real route and an admin can read it.
+ // **All six public ones now declare `eventUrl`, and Phase 14a is what made
+ // that legal.** Until it there was no public event page at all — `App.jsx`
+ // mounted nothing under `/site/events` — and `news.post` had already paid for
+ // that mistake once: its `postUrl` example named `/news/`, a path that
+ // did not exist, so the template editor previewed a link that was dead in
+ // every mail it sent. The variable arrived with the page it points at, which
+ // is what makes this a version bump (1 -> 2) rather than a correction.
+ //
+ // **It carries `?run=`, and the query string is the whole reason it is a run
+ // url and not an event url.** The page lives at the DEFINITION's slug, so a
+ // weekly event has one stable address — but every one of these triggers is
+ // about one OCCURRENCE, and a mail about last Friday's invasion whose link
+ // opened next Friday's would answer a different question from the one the
+ // reader clicked. `run.failed` keeps its own `runUrl` into the admin console
+ // and gains nothing here: an admin reading about broken machinery wants the
+ // console, not the storyline.
{
id: 'event.run.scheduled',
label: 'Event — scheduled',
@@ -202,7 +209,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
- version: 1,
+ version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -225,6 +232,12 @@ const TRIGGERS = [
{ name: 'startsAtLabel', type: 'string', required: false,
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
description: 'The start time written out in the shard-local zone, for a mail to read.' },
+ // The public page for THIS occurrence (Phase 14a). Relative, like
+ // `postUrl` and `runUrl`: the seam resolves it against the site's own
+ // base, and an absolute one baked in here would be wrong on every
+ // deployment but the first.
+ { name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
+ description: 'The public page for this occurrence.' },
],
},
{
@@ -235,7 +248,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
- version: 1,
+ version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -258,6 +271,12 @@ const TRIGGERS = [
{ name: 'startsAtLabel', type: 'string', required: false,
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
description: 'The start time written out in the shard-local zone, for a mail to read.' },
+ // The public page for THIS occurrence (Phase 14a). Relative, like
+ // `postUrl` and `runUrl`: the seam resolves it against the site's own
+ // base, and an absolute one baked in here would be wrong on every
+ // deployment but the first.
+ { name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
+ description: 'The public page for this occurrence.' },
],
},
{
@@ -268,7 +287,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
- version: 1,
+ version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -282,6 +301,12 @@ const TRIGGERS = [
description: 'Which phase this is, counting from 1.' },
{ name: 'phaseCount', type: 'int', required: true, example: 4,
description: 'How many phases the pinned version has in total.' },
+ // The public page for THIS occurrence (Phase 14a). Relative, like
+ // `postUrl` and `runUrl`: the seam resolves it against the site's own
+ // base, and an absolute one baked in here would be wrong on every
+ // deployment but the first.
+ { name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
+ description: 'The public page for this occurrence.' },
],
},
{
@@ -292,12 +317,18 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
- version: 1,
+ version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
+ // The public page for THIS occurrence (Phase 14a). Relative, like
+ // `postUrl` and `runUrl`: the seam resolves it against the site's own
+ // base, and an absolute one baked in here would be wrong on every
+ // deployment but the first.
+ { name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
+ description: 'The public page for this occurrence.' },
],
},
{
@@ -308,7 +339,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
- version: 1,
+ version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -324,6 +355,12 @@ const TRIGGERS = [
description: 'How many participants the run recorded. Zero when nothing collected any.' },
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
description: 'How long the run took, start to end, in whole minutes.' },
+ // The public page for THIS occurrence (Phase 14a). Relative, like
+ // `postUrl` and `runUrl`: the seam resolves it against the site's own
+ // base, and an absolute one baked in here would be wrong on every
+ // deployment but the first.
+ { name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
+ description: 'The public page for this occurrence.' },
],
},
{
@@ -334,7 +371,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
- version: 1,
+ version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -345,6 +382,12 @@ const TRIGGERS = [
// string is for the run console and would read as gibberish in a mail.
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
description: 'What the staff member gave as the reason, when they gave one.' },
+ // The public page for THIS occurrence (Phase 14a). Relative, like
+ // `postUrl` and `runUrl`: the seam resolves it against the site's own
+ // base, and an absolute one baked in here would be wrong on every
+ // deployment but the first.
+ { name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
+ description: 'The public page for this occurrence.' },
],
},
{
@@ -369,8 +412,9 @@ const TRIGGERS = [
description: 'The phase it failed in, when it had entered one.' },
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
description: 'The run’s last error, verbatim from the run row.' },
- // The one url variable in this file's Phase 10 block, and the reason is
- // that this route exists TODAY. See the note above the six.
+ // The admin console, not the public page — and this trigger gains no
+ // `eventUrl` at all. An admin reading that the machinery broke wants the
+ // steps and the errors, not the storyline. See the note above the six.
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
description: 'Site-relative path to the run console.' },
],
diff --git a/server/src/config/version.js b/server/src/config/version.js
index 0f2c4f9..24930b5 100644
--- a/server/src/config/version.js
+++ b/server/src/config/version.js
@@ -11,6 +11,25 @@
//
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
// would be a v2 mount); `server` is the informational package version.
+//
+// ── `capabilities` (Phase 14a) ──
+//
+// Opaque strings naming what CORE serves beyond the surface every backend has —
+// the same idea as a module's `capabilities` on GET /public/modules, and
+// deliberately the same word, so a client feature-detects one way rather than
+// two. They are a different LIST because core is not a module: publishing core
+// 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 the loader exists to make.
+//
+// The value is in what is ABSENT. A backend released before Events answers this
+// object with no `capabilities` key at all, so a client can tell an older site
+// from one that simply has nothing on its calendar — which it could not do by
+// probing /public/events, where "not built" and "temporarily down" look alike.
+//
+// Static, because these are compiled-in features rather than installed ones:
+// a core that has these routes always has them. An unknown string is to be
+// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
const pkg = require('../../package.json')
@@ -18,4 +37,6 @@ module.exports = {
service: 'runic-gateway', // stable backend identifier for first-run detection
api: 'v1', // API contract version (matches the /api/v1 mount)
server: pkg.version || '0.0.0', // server package version (informational)
+ // What core serves beyond the baseline. See the note above.
+ capabilities: ['events'],
}
diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js
index 2088c40..510e8f7 100644
--- a/server/src/engagement/templateSeeds.js
+++ b/server/src/engagement/templateSeeds.js
@@ -332,23 +332,29 @@ const SEEDS = [
// single absent variable renders nothing, in both parts). A standalone event
// has no `seriesName` and its line disappears rather than reading "Part of .".
//
- // **No `{{actionUrl}}` and no button, deliberately.** There is no public event
- // page until Phase 14, so the six public triggers declare no `url` variable at
- // all (see `coreTriggers.js`), and a button here would render as an inert grey
- // label in every mail — worse than none, because it advertises a link the
- // reader cannot follow. Phase 14 adds the variable and the block together.
+ // **The button arrived with the page it points at** (Phase 14a). Until then
+ // there was no public event page, the six public triggers declared no url
+ // variable, and a button here would have rendered as an inert grey label in
+ // every mail — worse than no button, because it advertises a link the reader
+ // cannot follow. `eventUrl` is optional and `email.button` drops itself when
+ // its url interpolates to nothing, so an event that is not public still mails
+ // correctly: the block disappears rather than degrading.
{
key: 'notify.event-started',
name: 'Event starting',
channel: 'email',
protected: false,
- seedVersion: 1,
+ // Bumped with the button. A deployment whose operator has not customized
+ // this template gets the new one; one that has is left alone and reported as
+ // stale, which is the whole mechanism.
+ seedVersion: 2,
subject: '{{title}} is starting',
variables: [
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
+ { name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
@@ -356,6 +362,7 @@ const SEEDS = [
text('summary', '{{summary}}'),
text('when', '{{startsAtLabel}}', { muted: true }),
text('series', '{{seriesName}}', { muted: true }),
+ button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
diff --git a/server/src/events/announce.js b/server/src/events/announce.js
index e4813f0..e8d0f76 100644
--- a/server/src/events/announce.js
+++ b/server/src/events/announce.js
@@ -92,10 +92,32 @@ async function baseFor(run) {
summary: definition.summary || undefined,
seriesName: definition.series_name || undefined,
timezone: run.timezone || definition.timezone || undefined,
+ eventUrl: eventUrl(definition, run),
definition,
}
}
+/**
+ * The public page for one occurrence (Phase 14a).
+ *
+ * **Site-relative, and it carries the run.** The page lives at the definition's
+ * slug — one stable address for a weekly event, which is what makes a link in
+ * Discord survive a retitle — so the occurrence has to be in the query string or
+ * a mail about last Friday's invasion would open next Friday's.
+ *
+ * **`undefined` when the event is not public**, rather than a path that answers
+ * 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
+ * declared optional precisely so its block can disappear from a template instead
+ * of rendering a dead button. That is `news.post`'s lesson applied before it
+ * costs anything: a link nobody can follow is worse than no link, because it
+ * advertises one.
+ */
+function eventUrl(definition, run) {
+ if (!definition.slug) return undefined
+ if (definition.state !== 'ready' || !definition.listed) return undefined
+ return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
+}
+
/**
* Fire one lifecycle trigger.
*
diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js
index a735abb..8342bed 100644
--- a/server/src/model/events/eventDefinitions.db.js
+++ b/server/src/model/events/eventDefinitions.db.js
@@ -11,6 +11,9 @@ const hydrate = (row) =>
row && {
...row,
spec: parseJson(row.spec, null),
+ // TINYINT(1) arrives as 0/1. Every reader of this column asks a yes/no
+ // question, and the public model's filters compare against a boolean.
+ listed: Boolean(row.listed),
}
// `current_version` is joined rather than stored: the list screen shows "v3" and
@@ -60,8 +63,8 @@ const insert = async (d) => {
const result = await query(
`INSERT INTO event_definitions
(title, slug, summary, body, image_url, owner_module, series_id, series_order,
- concurrency_key, grace_seconds, timezone, spec, created_by, updated_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
d.title,
d.slug,
@@ -74,6 +77,7 @@ const insert = async (d) => {
d.concurrency_key,
d.grace_seconds,
d.timezone,
+ d.listed ? 1 : 0,
JSON.stringify(d.spec),
d.created_by,
d.created_by,
@@ -87,7 +91,7 @@ const update = (id, d) =>
`UPDATE event_definitions
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
- spec = ?, updated_by = ?
+ listed = ?, spec = ?, updated_by = ?
WHERE id = ?`,
[
d.title,
@@ -100,6 +104,7 @@ const update = (id, d) =>
d.concurrency_key,
d.grace_seconds,
d.timezone,
+ d.listed ? 1 : 0,
JSON.stringify(d.spec),
d.updated_by,
id,
@@ -140,20 +145,51 @@ const markReady = (id, versionId, userId) =>
* its arc exactly as a materialised run is, and a second query to learn the name
* of a row this one already reached would be two round trips for a join.
*/
-const findSchedulable = async () => {
+const findSchedulable = async ({ listedOnly = false } = {}) => {
const rows = await query(
- `SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
- d.current_version_id, d.series_id, v.spec AS version_spec,
+ `SELECT d.id, d.title, d.slug, d.summary, d.image_url, d.timezone, d.grace_seconds,
+ d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec,
s.name AS series_name, s.slug AS series_slug
FROM event_definitions d
JOIN event_versions v ON v.id = d.current_version_id
LEFT JOIN event_series s ON s.id = d.series_id
- WHERE d.state = 'ready'
+ WHERE d.state = 'ready'${listedOnly ? ' AND d.listed = 1' : ''}
ORDER BY d.id`,
)
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
}
+/**
+ * One definition by slug, for the PUBLIC event page (Phase 14a).
+ *
+ * `ready` and `listed` are both in the WHERE rather than checked by the caller,
+ * so an unlisted event answers exactly as a nonexistent one does — a 404 that
+ * cannot be told from "no such slug". A caller that filtered afterwards would
+ * be one forgotten early-return away from publishing a draft.
+ *
+ * An ARCHIVED definition is deliberately absent too. Archiving is what delete
+ * means on the admin screen, and a page that kept answering afterwards would
+ * make the only delete this feature has do nothing an operator could see.
+ */
+const getPublicBySlug = async (slug) => {
+ const [row] = await query(
+ `${SELECT_LIST} WHERE d.slug = ? AND d.state = 'ready' AND d.listed = 1`,
+ [slug],
+ )
+ return hydrate(row)
+}
+
+/** Every listed, ready definition in one series, in the arc's own order. */
+const listPublicBySeries = async (seriesId) => {
+ const rows = await query(
+ `${SELECT_LIST}
+ WHERE d.series_id = ? AND d.state = 'ready' AND d.listed = 1
+ ORDER BY d.series_order, d.id`,
+ [seriesId],
+ )
+ return rows.map(hydrate)
+}
+
/**
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
@@ -166,6 +202,8 @@ module.exports = {
list,
getById,
getBySlug,
+ getPublicBySlug,
+ listPublicBySeries,
slugTaken,
findSchedulable,
insert,
diff --git a/server/src/model/events/eventDefinitions.model.js b/server/src/model/events/eventDefinitions.model.js
index c4277de..373b8fe 100644
--- a/server/src/model/events/eventDefinitions.model.js
+++ b/server/src/model/events/eventDefinitions.model.js
@@ -124,6 +124,15 @@ async function validate(input, { existing = null } = {}) {
const seriesOrder = Number(seriesOrderRaw)
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
+ // Whether this event is announced on the public calendar (Phase 14a). It is
+ // NOT whether it may run: `state` answers that, and the two are separate
+ // precisely because publishing is what makes a definition runnable — an
+ // unlisted event still schedules, still runs and is still on the admin
+ // calendar. A missing key means "leave it as it was", and a NEW definition
+ // defaults to listed, which is the column's own default and the ordinary
+ // case; unlisting is the deliberate act.
+ const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
+
// ── the spec ──
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
@@ -159,6 +168,7 @@ async function validate(input, { existing = null } = {}) {
concurrency_key: concurrencyKey,
grace_seconds: graceSeconds,
timezone,
+ listed,
spec: checked.spec,
},
}
diff --git a/server/src/model/events/eventPublic.model.js b/server/src/model/events/eventPublic.model.js
new file mode 100644
index 0000000..19a1c71
--- /dev/null
+++ b/server/src/model/events/eventPublic.model.js
@@ -0,0 +1,409 @@
+// ── The public event surface ───────────────────────────────────────────────
+//
+// EVENTS.md § API surface, and Phase 14a of EVENTS_PLAN.md: the calendar an
+// anonymous visitor reads, one event's page, and an arc.
+//
+// **This file is a projection, and the projection is the security boundary.**
+// Every other reader of these tables is staff, and every field they are shown is
+// one somebody with a role was allowed to see. What comes out of here is read by
+// nobody at all, so the rule is the opposite of the admin shapes': nothing is
+// spread, and a field reaches a public entry because a line below put it there.
+// The day somebody adds a column to `event_runs` — a claim token, an operator's
+// note, a last error — a `{ ...run }` anywhere here would publish it, silently,
+// in the release after the one anybody reviewed.
+//
+// Three things are absent from every shape below, and each is a decision:
+//
+// • **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; the steps are the operator's.
+// • **Health, cleanup, claims and errors.** A degraded run is a fact about the
+// deployment's plumbing. "The event is running" is the fact about the event.
+// • **`member_key`.** It is the game's own identifier for a character, it is
+// module-opaque, and core cannot say what it discloses — so it stays unsent
+// even on a results table where every other column is published.
+//
+// **What makes something public is `listed` AND `ready` AND not a rehearsal**,
+// and all three live in SQL (`eventDefinitions.db.getPublicBySlug`, and
+// `publicOnly` on `eventRuns.db.listInWindow`). Filtering in JavaScript after
+// the read would work exactly as well, right up until the first caller that
+// forgot to.
+
+const definitionsDb = require('./eventDefinitions.db')
+const runsDb = require('./eventRuns.db')
+const seriesDb = require('./eventSeries.db')
+const versionsDb = require('./eventVersions.db')
+const participantsDb = require('./eventRunParticipants.db')
+const calendarModel = require('./eventCalendar.model')
+const recurrence = require('../../events/recurrence')
+
+// The public calendar's window when a caller names neither end: now through a
+// month out. A visitor arriving at /site/events wants "what is on", and a client
+// that had to compute a window before it could ask anything would make every
+// deep link carry two ISO instants.
+const DEFAULT_WINDOW_DAYS = 31
+
+// How many past occurrences an event page carries. It shows what is next and
+// what happened recently; the whole history of a three-year-old weekly event is
+// a different screen and nobody has asked for one.
+const PAST_RUNS = 10
+const RESULTS_LIMIT = 100
+
+// The status words a visitor is told. `paused` maps to `live` deliberately: an
+// operator holding a run for two minutes while they deal with something is not a
+// state a public page should render, and a page that said "paused" would invite
+// a question whose answer is internal.
+const PUBLIC_STATUS = {
+ scheduled: 'scheduled',
+ starting: 'live',
+ running: 'live',
+ paused: 'live',
+ ending: 'live',
+ completed: 'completed',
+ cancelled: 'cancelled',
+ failed: 'cancelled',
+ missed: 'cancelled',
+}
+
+/**
+ * The public status word for a run.
+ *
+ * **`failed` and `missed` are published as `cancelled`**, which is the mapping
+ * worth defending. To a visitor the three are one event: it was on the calendar
+ * and it did not happen. The difference between them is entirely about the
+ * deployment — `failed` names broken machinery, `missed` names a process that
+ * was down when the schedule came round — so publishing either word would tell a
+ * stranger something true about the server and nothing about the event.
+ */
+const publicStatus = (status) => PUBLIC_STATUS[status] || 'scheduled'
+
+/** Is this a run a visitor should be shown as happening now? */
+const isLive = (status) => publicStatus(status) === 'live'
+
+/**
+ * The label of the phase a run is in, resolved from the PINNED version's spec.
+ *
+ * A phase id is a slug an author typed and the label is what they meant it to
+ * read as, so a page rendering the id would show `phase-2` to the public. A
+ * phase the spec does not name answers null and the page shows nothing, which is
+ * the right answer for a version edited since: the run pinned the old spec and
+ * the old spec is what it is executing.
+ */
+function phaseLabel(spec, phaseId) {
+ if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
+ const phase = spec.phases.find((p) => p && p.id === phaseId)
+ return (phase && (phase.label || phase.id)) || null
+}
+
+/** One calendar entry, from a materialised run. */
+const publicRunEntry = (run) => ({
+ kind: 'run',
+ title: run.definition_title,
+ slug: run.definition_slug,
+ seriesName: run.series_name || null,
+ seriesSlug: run.series_slug || null,
+ scheduledFor: run.scheduled_for,
+ timezone: run.timezone,
+ status: publicStatus(run.status),
+ live: isLive(run.status),
+})
+
+/**
+ * One calendar entry, from a projection.
+ *
+ * A projection is arithmetic past the materialisation horizon (§I), and the
+ * public entry keeps the distinction for the visitor's version of the operator's
+ * reason: a forecast three weeks out is a plan rather than a booking, and a page
+ * drawing the two identically would promise something nothing has committed to.
+ * `adjusted` rides along because a DST-shifted occurrence is worth explaining
+ * before it happens rather than after.
+ */
+const publicProjectedEntry = (definition, occurrence) => ({
+ kind: 'projected',
+ title: definition.title,
+ slug: definition.slug,
+ seriesName: definition.series_name || null,
+ seriesSlug: definition.series_slug || null,
+ scheduledFor: occurrence.at,
+ timezone: definition.timezone,
+ status: 'scheduled',
+ live: false,
+ adjusted: occurrence.adjusted,
+ shiftMinutes: occurrence.shiftMinutes,
+})
+
+/**
+ * The public calendar for a window.
+ *
+ * **The run half is read here rather than borrowed from `eventCalendar.model`**,
+ * and the reason is the file header's: that model answers with `status`,
+ * `health`, `version` and `waitingSteps` on every entry, so reusing it would
+ * mean building the public answer by DELETING fields from an operator's, which
+ * is the direction that fails silently. The arithmetic IS shared —
+ * `occurrencesBetween` is the same function the runner calls, so a forecast
+ * still cannot disagree with what later appears — and so are the window bound
+ * and the entry cap, which are a defence against an expensive query on the one
+ * surface that has no login in front of it.
+ */
+async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
+ const start = from ? new Date(from) : new Date(now)
+ const end = to ? new Date(to) : new Date(start.getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
+
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ return { ok: false, status: 400, errors: ['from and to must be dates'] }
+ }
+ if (end <= start) {
+ return { ok: false, status: 400, errors: ['to must be after from'] }
+ }
+ if (end - start > calendarModel.MAX_WINDOW_DAYS * recurrence.DAY_MS) {
+ return {
+ ok: false,
+ status: 400,
+ errors: [`the window may span at most ${calendarModel.MAX_WINDOW_DAYS} days`],
+ }
+ }
+
+ const runs = await runsDb.listInWindow({ from: start, to: end, seriesId, publicOnly: true })
+ const entries = runs.map(publicRunEntry)
+
+ // Every instant a run already occupies, so the fortnight inside the horizon is
+ // not drawn twice — and so a CANCELLED occurrence is not re-forecast as though
+ // it were still coming. The same key the admin calendar uses, for the same
+ // reason: projections are per definition at the empty scope.
+ const taken = new Set(
+ runs
+ .filter((r) => !r.scope)
+ .map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
+ )
+
+ const definitions = await definitionsDb.findSchedulable({ listedOnly: true })
+ for (const definition of definitions) {
+ if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
+ const schedule = definition.version_spec?.schedule
+ if (!schedule || schedule.kind === 'manual') continue
+ let occurrences = []
+ try {
+ occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', start, end)
+ } catch {
+ // A version whose schedule the recurrence engine will not read is one the
+ // runner will not expand either. The calendar then shows that definition's
+ // materialised runs and no forecast, rather than failing the whole page.
+ continue
+ }
+ for (const occurrence of occurrences) {
+ if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
+ entries.push(publicProjectedEntry(definition, occurrence))
+ }
+ }
+
+ entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
+
+ return {
+ ok: true,
+ status: 200,
+ window: { from: start, to: end },
+ entries: entries.slice(0, calendarModel.MAX_ENTRIES),
+ truncated: entries.length > calendarModel.MAX_ENTRIES,
+ }
+}
+
+/** One participant, as a results table publishes them. */
+const publicParticipant = (p) => ({
+ // NOT `memberKey` — see the file header. A display name is whatever the module
+ // chose to put in `meta`, because core has no name for a character and must
+ // not invent one from the key.
+ name: (p.meta && (p.meta.name || p.meta.displayName)) || null,
+ score: p.score,
+ rank: p.rank_at,
+ meta: p.meta || null,
+})
+
+/** One occurrence, as an event page lists it. */
+const publicOccurrence = (run, spec) => ({
+ runId: run.id,
+ scheduledFor: run.scheduled_for,
+ timezone: run.timezone,
+ startedAt: run.started_at,
+ endedAt: run.ended_at,
+ status: publicStatus(run.status),
+ live: isLive(run.status),
+ scope: run.scope || null,
+ phase: isLive(run.status) ? phaseLabel(spec, run.current_phase) : null,
+ resultsPublishedAt: run.results_published_at || null,
+})
+
+/**
+ * One event's public page: the storyline, its arc, its occurrences, and a
+ * results table when there is one to show.
+ *
+ * **`runId` selects WHICH occurrence the results are about, and it is optional
+ * for a reason that exists only because of the announcements.** The page lives
+ * at the definition's slug, so a weekly event has one address and a visitor
+ * arriving at it should be shown what is next. But an `event.run.completed` mail
+ * is about ONE occurrence, and a link in it that opened next Friday's would
+ * answer a different question from the one the reader clicked. So the trigger's
+ * `eventUrl` carries `?run=`, and this is what resolves it.
+ *
+ * **A `runId` that does not belong to this definition is ignored rather than
+ * refused.** It names some other event's run, or none; the honest answer to
+ * "show me this event" is still this event, and a 404 for the whole page would
+ * turn a stale link in a months-old mail into a dead end rather than a page
+ * about the thing the mail was about.
+ */
+async function event(slug, { runId = null } = {}) {
+ const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
+ if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
+
+ const series = definition.series_id ? await seriesDb.getById(definition.series_id) : null
+ const runs = await runsDb.listPublicForDefinition(definition.id, PAST_RUNS + 20)
+
+ const now = Date.now()
+ const live = runs.filter((r) => isLive(r.status))
+
+ // **Split on the instant, not on the status**, and the difference is visible
+ // in both directions. A `missed` run is in the past whatever its status says,
+ // and so is a `scheduled` one whose moment went by while the runner had not
+ // reached it — but a run an operator CANCELLED next Friday is still next
+ // Friday, and filing it under "previously" tells a visitor it already
+ // happened, which is the one thing that is certainly untrue about it. That a
+ // cancelled occurrence still appears under what is coming is the point:
+ // "next Friday is off" is exactly what somebody checking the calendar came to
+ // find out.
+ const upcoming = runs
+ .filter((r) => !live.includes(r) && new Date(r.scheduled_for).getTime() >= now)
+ .sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for))
+ const past = runs.filter((r) => !live.includes(r) && !upcoming.includes(r)).slice(0, PAST_RUNS)
+
+ // `next` is narrower than `upcoming[0]`, deliberately: the headline answers
+ // "when is the next one", and a cancelled occurrence is not one. An event
+ // whose only future occurrence has been called off has no `next` and says so,
+ // while the cancellation itself is still listed below.
+ const next = upcoming.find((r) => r.status === 'scheduled') || null
+
+ // Which occurrence the results table is about. An explicit `run` wins; then a
+ // live one, because that is what the visitor is looking at; then the most
+ // recent that actually published results, because a page with a table on it is
+ // more use than one with an empty heading.
+ const named = runId ? runs.find((r) => String(r.id) === String(runId)) : null
+ const resultsRun = named || live[0] || past.find((r) => r.results_published_at) || null
+
+ let participants = []
+ if (resultsRun && resultsRun.results_published_at) {
+ participants = (await participantsDb.listForRun(resultsRun.id, RESULTS_LIMIT)).map(
+ publicParticipant,
+ )
+ }
+
+ // Phase labels come from the version the run is EXECUTING rather than from the
+ // definition's working draft, which an author may be halfway through editing.
+ // One extra read, and only when there is a run to label at all.
+ const specRun = named || live[0] || null
+ const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
+
+ return {
+ ok: true,
+ status: 200,
+ event: {
+ title: definition.title,
+ slug: definition.slug,
+ summary: definition.summary,
+ body: definition.body,
+ imageUrl: definition.image_url,
+ timezone: definition.timezone,
+ series: series ? { name: series.name, slug: series.slug } : null,
+ live: live.length > 0,
+ current: live[0] ? publicOccurrence(live[0], spec) : null,
+ next: next ? publicOccurrence(next, spec) : null,
+ upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
+ past: past.map((r) => publicOccurrence(r, spec)),
+ results:
+ resultsRun && resultsRun.results_published_at
+ ? {
+ runId: resultsRun.id,
+ scheduledFor: resultsRun.scheduled_for,
+ publishedAt: resultsRun.results_published_at,
+ participants,
+ }
+ : null,
+ },
+ }
+}
+
+/**
+ * One arc: the series, and the listed events in it in the order an editor
+ * dragged them into.
+ *
+ * **A series with no listed events is a 404 rather than an empty page.** The 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.
+ */
+async function series(slug) {
+ const row = await seriesDb.getBySlug(String(slug || ''))
+ if (!row) return { ok: false, status: 404, errors: ['Not found'] }
+
+ const definitions = await definitionsDb.listPublicBySeries(row.id)
+ if (!definitions.length) return { ok: false, status: 404, errors: ['Not found'] }
+
+ return {
+ ok: true,
+ status: 200,
+ series: {
+ name: row.name,
+ slug: row.slug,
+ description: row.description,
+ events: definitions.map((d) => ({
+ title: d.title,
+ slug: d.slug,
+ summary: d.summary,
+ imageUrl: d.image_url,
+ })),
+ },
+ }
+}
+
+/**
+ * One account's participation history.
+ *
+ * Self-scoped by the caller's own id and nothing else. There is no route on
+ * which one account reads another's, and deliberately no id parameter that could
+ * later grow into one.
+ */
+async function history(userId, { limit = 50, before = null } = {}) {
+ const rows = await participantsDb.listForUser(userId, { limit, before })
+ return {
+ ok: true,
+ status: 200,
+ entries: rows.map((r) => ({
+ id: r.id,
+ runId: r.run_id,
+ title: r.definition_title,
+ slug: r.definition_slug,
+ seriesName: r.series_name || null,
+ seriesSlug: r.series_slug || null,
+ scheduledFor: r.scheduled_for,
+ startedAt: r.started_at,
+ endedAt: r.ended_at,
+ timezone: r.timezone,
+ status: publicStatus(r.status),
+ joinedAt: r.joined_at,
+ score: r.score,
+ // Null until `core.results.publish` ran. The screen says so rather than
+ // inventing a position nobody computed.
+ rank: r.rank_at,
+ resultsPublishedAt: r.results_published_at || null,
+ meta: r.meta || null,
+ })),
+ }
+}
+
+module.exports = {
+ calendar,
+ event,
+ series,
+ history,
+ publicStatus,
+ phaseLabel,
+ DEFAULT_WINDOW_DAYS,
+ PAST_RUNS,
+}
diff --git a/server/src/model/events/eventRunParticipants.db.js b/server/src/model/events/eventRunParticipants.db.js
index 4c54f88..98202af 100644
--- a/server/src/model/events/eventRunParticipants.db.js
+++ b/server/src/model/events/eventRunParticipants.db.js
@@ -75,6 +75,52 @@ async function listForRun(runId, limit = 500) {
return rows.map(hydrate)
}
+/**
+ * One account's participation history, most recent event first (Phase 14a).
+ *
+ * **Joined all the way out to the definition, and the join is the access
+ * control.** A rehearsal is excluded by §D's own rule, and an unlisted
+ * definition is excluded because unlisting is what an operator does to an event
+ * they are not announcing — a history that named it would announce it to
+ * everyone who attended, which is everyone who could tell anybody.
+ *
+ * `member_key` is NOT selected. It is the game's identifier for a character and
+ * the caller is a player reading their own page; the run, the date, the score
+ * and the rank are what a history is, and the key adds a module-opaque string
+ * nothing on the page can render.
+ *
+ * `rank_at` is null until results are published, and that is a real state the
+ * screen shows rather than an error — a run whose participants are collected
+ * and unranked is exactly what Phase 10 made visible on the admin side.
+ */
+async function listForUser(userId, { limit = 50, before = null } = {}) {
+ const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
+ const args = [userId]
+ // A keyset cursor on the participation row rather than an offset: the list
+ // gains a row every time the reader attends something, and an offset page two
+ // would skip whatever arrived in between.
+ const cursor = before ? ' AND p.id < ?' : ''
+ if (before) args.push(before)
+ const rows = await query(
+ `SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
+ r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
+ r.timezone, r.results_published_at,
+ d.title AS definition_title, d.slug AS definition_slug,
+ s.name AS series_name, s.slug AS series_slug
+ FROM event_run_participants p
+ JOIN event_runs r ON r.id = p.run_id
+ JOIN event_definitions d ON d.id = r.definition_id
+ LEFT JOIN event_series s ON s.id = d.series_id
+ WHERE p.user_id = ?${cursor}
+ AND r.rehearsal = 0
+ AND d.listed = 1
+ ORDER BY p.id DESC
+ LIMIT ${n}`,
+ args,
+ )
+ return rows.map(hydrate)
+}
+
/** How many the run has. Its own query because the trigger payload needs only this. */
async function countForRun(runId) {
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
@@ -116,4 +162,4 @@ async function rankRun(runId) {
return Number(result.affectedRows || 0)
}
-module.exports = { record, listForRun, countForRun, rankRun }
+module.exports = { record, listForRun, listForUser, countForRun, rankRun }
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index 86659c8..0825f4f 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -116,13 +116,30 @@ const materialise = async (run) => {
* a run records the zone it was COMPUTED in and a definition's zone can be
* edited afterwards.
*/
-const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
+const listInWindow = async ({
+ from,
+ to,
+ status = null,
+ scope = null,
+ seriesId = null,
+ limit = 500,
+ publicOnly = false,
+} = {}) => {
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
const args = [from, to]
if (status) {
where.push('r.status = ?')
args.push(status)
}
+ // The public calendar's two exclusions, in SQL rather than in the model that
+ // maps the rows. A rehearsal "is excluded from the public calendar and from
+ // participation history" by §D's own column comment, and an unlisted
+ // definition is one an operator chose not to announce. Both belong in the
+ // query because a filter applied after the read is a filter somebody can
+ // forget in the next caller.
+ if (publicOnly) {
+ where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
+ }
if (scope !== null && scope !== undefined) {
where.push('r.scope = ?')
args.push(scope)
@@ -497,6 +514,33 @@ const reclaimStale = async (now) => {
return Number(result?.affectedRows || 0)
}
+/**
+ * One definition's public occurrences, newest first (Phase 14a).
+ *
+ * Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
+ * reason. The definition's own `listed`/`state` are NOT re-checked: the only
+ * caller has already resolved the definition through `getPublicBySlug`, and a
+ * second copy of that rule is a second thing to keep in step with the first.
+ *
+ * `scheduled` runs come back too — an upcoming occurrence is exactly what a
+ * visitor came to the page for — and the caller splits past from future on the
+ * instant rather than on the status, because a `missed` run is in the past
+ * whatever its status says.
+ */
+const listPublicForDefinition = async (definitionId, limit = 50) => {
+ const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
+ const rows = await query(
+ `SELECT r.*, v.version AS version_number
+ FROM event_runs r
+ JOIN event_versions v ON v.id = r.version_id
+ WHERE r.definition_id = ? AND r.rehearsal = 0
+ ORDER BY r.scheduled_for DESC, r.id DESC
+ LIMIT ${n}`,
+ [definitionId],
+ )
+ return rows.map(hydrate)
+}
+
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
const terminalBefore = async (before, limit = 500) => {
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
@@ -516,6 +560,7 @@ module.exports = {
getById,
materialise,
listInWindow,
+ listPublicForDefinition,
repinScheduled,
listScheduledFor,
findOccurrence,
diff --git a/server/src/model/events/eventSeries.db.js b/server/src/model/events/eventSeries.db.js
index 17a6658..6675bfc 100644
--- a/server/src/model/events/eventSeries.db.js
+++ b/server/src/model/events/eventSeries.db.js
@@ -29,6 +29,11 @@ const getById = async (id) => {
return row || null
}
+const getBySlug = async (slug) => {
+ const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
+ return row || null
+}
+
const exists = async (id) => {
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
return Boolean(row)
@@ -70,4 +75,4 @@ const update = (id, s) =>
*/
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
-module.exports = { list, getById, exists, slugTaken, insert, update, remove }
+module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 2dce68f..cb5aa7e 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -69,6 +69,10 @@ const shapeDefinition = (d) => ({
concurrencyKey: d.concurrency_key,
graceSeconds: d.grace_seconds,
timezone: d.timezone,
+ // Whether the public calendar announces it (Phase 14a). Not whether it may
+ // run — an unlisted event schedules and runs exactly as a listed one does,
+ // and is on THIS screen either way.
+ listed: Boolean(d.listed),
spec: d.spec,
createdAt: d.created_at,
updatedAt: d.updated_at,
diff --git a/server/src/router/v1/player/events.controller.js b/server/src/router/v1/player/events.controller.js
new file mode 100644
index 0000000..8a89773
--- /dev/null
+++ b/server/src/router/v1/player/events.controller.js
@@ -0,0 +1,29 @@
+// Player · Events — the one handler behind /player/events/history (Phase 14a).
+//
+// Self-scoped on `req.user.id` and on nothing the caller sent. The model does
+// the same joins the public surface does — rehearsals and unlisted events are
+// absent — so a participant cannot learn from their own history that an
+// unannounced event exists.
+
+const events = require('../../../model/events/eventPublic.model')
+const log = require('../../../utils/logger')('player:events')
+
+async function getHistory(req, res) {
+ try {
+ // A non-integer cursor is dropped rather than bound. `Number('abc')` is NaN,
+ // and NaN reaching a placeholder is a driver-level failure — a 500 for what
+ // is a malformed query string, and the honest answer to one is the first
+ // page.
+ const cursor = Number(req.query.before)
+ const result = await events.history(req.user.id, {
+ limit: req.query.limit ? Number(req.query.limit) : undefined,
+ before: Number.isInteger(cursor) && cursor > 0 ? cursor : null,
+ })
+ return res.json(result)
+ } catch (err) {
+ log.error('participation history failed', { message: err.message })
+ return res.status(500).json({ message: 'Internal Server Error' })
+ }
+}
+
+module.exports = { getHistory }
diff --git a/server/src/router/v1/player/events.router.js b/server/src/router/v1/player/events.router.js
new file mode 100644
index 0000000..5b904d0
--- /dev/null
+++ b/server/src/router/v1/player/events.router.js
@@ -0,0 +1,39 @@
+// Player · Events — this account's participation history (EVENTS.md § API
+// surface, Phase 14a). Mounted at /api/v1/player/events by player/index.js.
+//
+// The group gate is `requireAuth` and it is the whole gate: this is role-agnostic
+// self-service, like the rest of /player. Staff are a superset of players (see
+// player/index.js), and an admin reading their own attendance is exactly as
+// ordinary as a player doing it.
+//
+// **No backtick in a `#swagger.parameters` annotation.** Unlike `#swagger.summary`
+// and `#swagger.description`, which are plain strings, a parameters annotation is
+// parsed as an object literal — a backtick inside its quoted `description` is
+// rewritten as a quote, and swagger-autogen then DROPS the whole annotation with a
+// syntax error rather than failing the build.
+//
+// **There is no id parameter, deliberately.** The history is `req.user.id`'s and
+// nothing else's; a route that took a user id would be one middleware mistake
+// away from publishing who attended what, which is a question about people
+// rather than about events.
+
+const express = require('express')
+
+const ctrl = require('./events.controller')
+
+const eventsRouter = express.Router()
+
+eventsRouter.get(
+ '/history',
+ // #swagger.tags = ['Player · Events']
+ // #swagger.summary = 'This account’s event participation'
+ // #swagger.description = 'The events this account took part in, most recent first — the run, when it was, the score a module reported, and the rank once results were published. `rank` is null until then, which is a real state rather than an error. Rehearsals and unlisted events are absent, the same rule the public calendar follows. Keyset paging: pass the last entry’s `id` as `before`.'
+ // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
+ // #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Cursor: the id of the last entry on the previous page.' }
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'Participation history', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerEventHistory" } } } } */
+ /* #swagger.responses[401] = { description: 'Not signed in', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ ctrl.getHistory,
+)
+
+module.exports = eventsRouter
diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js
index f635b52..465ed42 100644
--- a/server/src/router/v1/player/index.js
+++ b/server/src/router/v1/player/index.js
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
+const eventsRouter = require('./events.router')
const playerRouter = express.Router()
@@ -39,6 +40,9 @@ const playerRouter = express.Router()
playerRouter.use(noindex, requireAuth)
playerRouter.use('/appeals', appealsRouter)
+// This account's own event participation (Phase 14a). Self-scoped on
+// req.user.id, like everything else in this group.
+playerRouter.use('/events', eventsRouter)
playerRouter.use('/teams', teamsRouter)
// Same prefix, second router. The forum and the leader-exercised grant flow are a
// different capability from "the caller's own Teams", and splitting them keeps
diff --git a/server/src/router/v1/public/events.controller.js b/server/src/router/v1/public/events.controller.js
new file mode 100644
index 0000000..a156300
--- /dev/null
+++ b/server/src/router/v1/public/events.controller.js
@@ -0,0 +1,68 @@
+// Public · Events — the anonymous event surface (EVENTS.md § API surface).
+//
+// Phase 14a. Three reads and no writes: the calendar, one event, one arc.
+//
+// **Every one of them is a thin pass-through to `eventPublic.model`, and that is
+// deliberate.** The projection — which fields exist at all on a public entry — is
+// the security boundary, and it belongs in one file rather than in three
+// controllers that would each have to remember it. What is left here is the
+// HTTP: parse the query, map the model's `status` onto a response code, and turn
+// a thrown read into a 500 rather than a stack trace.
+//
+// **A 404 here means "no such public event"** and cannot be told from "no such
+// slug at all". A draft, an archived definition and an unlisted one answer
+// identically, which is the whole point: an operator who has not announced
+// something has not announced its existence either.
+
+const events = require('../../../model/events/eventPublic.model')
+const log = require('../../../utils/logger')('public:events')
+
+const fail = (res, err, what) => {
+ log.error(`${what} failed`, { message: err.message })
+ return res.status(500).json({ message: 'Internal Server Error' })
+}
+
+const answer = (res, result) =>
+ result.ok
+ ? res.json(result)
+ : res.status(result.status || 400).json({ message: result.errors?.[0] || 'Bad Request', errors: result.errors })
+
+async function getCalendar(req, res) {
+ try {
+ const seriesId = req.query.seriesId ? Number(req.query.seriesId) : null
+ if (req.query.seriesId && !Number.isInteger(seriesId)) {
+ return res.status(400).json({ message: 'seriesId must be an integer' })
+ }
+ const result = await events.calendar({
+ from: req.query.from || null,
+ to: req.query.to || null,
+ seriesId,
+ })
+ return answer(res, result)
+ } catch (err) {
+ return fail(res, err, 'public calendar')
+ }
+}
+
+async function getEvent(req, res) {
+ try {
+ // `run` is optional and un-validated beyond being carried through as a
+ // string: the model matches it against this definition's own runs and
+ // ignores anything else, so a garbage value renders the page rather than an
+ // error. See the model's note on why it is not refused.
+ const result = await events.event(req.params.slug, { runId: req.query.run || null })
+ return answer(res, result)
+ } catch (err) {
+ return fail(res, err, 'public event')
+ }
+}
+
+async function getSeries(req, res) {
+ try {
+ return answer(res, await events.series(req.params.slug))
+ } catch (err) {
+ return fail(res, err, 'public series')
+ }
+}
+
+module.exports = { getCalendar, getEvent, getSeries }
diff --git a/server/src/router/v1/public/events.router.js b/server/src/router/v1/public/events.router.js
new file mode 100644
index 0000000..7d451b7
--- /dev/null
+++ b/server/src/router/v1/public/events.router.js
@@ -0,0 +1,60 @@
+// Public · Events — mounted at /api/v1/public/events by public/index.js.
+//
+// No group gate: this is the anonymous surface, and `siteMode` is applied per
+// route as it is everywhere else in this tier — during maintenance only an admin
+// with a valid session sees content.
+//
+// Declaration order: '/' is literal and precedes ':slug', and 'series/:slug' is
+// declared BEFORE ':slug' although it could not be shadowed by it (two segments
+// against one). It stays above so the relationship is visible to whoever adds
+// the next route here — and because the one route bug this feature has already
+// shipped was exactly a static/dynamic ranking surprise, one tier up in React
+// Router (see App.jsx's note above `events/:id`).
+
+const express = require('express')
+
+const ctrl = require('./events.controller')
+const siteMode = require('../../../middleware/siteMode')
+
+const eventsRouter = express.Router()
+
+eventsRouter.get(
+ '/',
+ // #swagger.tags = ['Public · Events']
+ // #swagger.summary = 'The public event calendar'
+ // #swagger.description = 'Upcoming, live and recent events in a window, ascending by instant. An entry is one of two things and says which: a `run` is a materialised occurrence, and a `projected` entry is arithmetic past the materialisation horizon — a forecast, with nothing committed to it, which a client should draw as such. Instants are UTC and each entry carries the EVENT\'s own timezone, because a shard-local 8pm means the shard\'s evening to everyone reading it; the reader\'s own zone places the entry in a month grid. Rehearsals and unlisted events are absent. Defaults to now through 31 days out; the window may span at most 92 days.'
+ // #swagger.parameters['from'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window start (ISO). Defaults to now.' }
+ // #swagger.parameters['to'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window end (ISO). Defaults to 31 days after the start.' }
+ // #swagger.parameters['seriesId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Restrict to one arc.' }
+ /* #swagger.responses[200] = { description: 'The calendar', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventCalendar" } } } } */
+ /* #swagger.responses[400] = { description: 'Bad window', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ siteMode,
+ ctrl.getCalendar,
+)
+
+eventsRouter.get(
+ '/series/:slug',
+ // #swagger.tags = ['Public · Events']
+ // #swagger.summary = 'One arc'
+ // #swagger.description = 'A series and the listed events in it, in the order an editor arranged them. A series with no listed events answers 404 rather than 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.'
+ // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The series slug.' }
+ /* #swagger.responses[200] = { description: 'The arc', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventSeries" } } } } */
+ /* #swagger.responses[404] = { description: 'No such arc, or nothing in it is listed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ siteMode,
+ ctrl.getSeries,
+)
+
+eventsRouter.get(
+ '/:slug',
+ // #swagger.tags = ['Public · Events']
+ // #swagger.summary = 'One event'
+ // #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
+ // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
+ // #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
+ /* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
+ /* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ siteMode,
+ ctrl.getEvent,
+)
+
+module.exports = eventsRouter
diff --git a/server/src/router/v1/public/index.js b/server/src/router/v1/public/index.js
index 139a8a0..4feaa8e 100644
--- a/server/src/router/v1/public/index.js
+++ b/server/src/router/v1/public/index.js
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
+const eventsRouter = require('./events.router')
const engagementRouter = require('./engagement.router')
const siteRouter = require('./site.router')
@@ -42,6 +43,10 @@ publicRouter.use('/modules', modulesRouter)
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
// content above it.
publicRouter.use('/teams', teamsRouter)
+// Events. A core prefix like /teams: the calendar, the event page and the arc
+// are core's surface even when every step an event dispatches belongs to a
+// module. Site-mode gated per route, like the content above it.
+publicRouter.use('/events', eventsRouter)
// The unauthenticated half of the engagement system: today exactly the
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
// token names is a channel and a scope and a scope is not always a Team
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 4e42307..9a744c3 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -15684,6 +15684,71 @@
]
}
},
+ "/api/v1/player/events/history": {
+ "get": {
+ "tags": [
+ "Player · Events"
+ ],
+ "summary": "This account’s event participation",
+ "description": "The events this account took part in, most recent first — the run, when it was, the score a module reported, and the rank once results were published. `rank` is null until then, which is a real state rather than an error. Rehearsals and unlisted events are absent, the same rule the public calendar follows. Keyset paging: pass the last entry’s `id` as `before`.",
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Page size, max 200 (default 50)."
+ },
+ {
+ "name": "before",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Cursor: the id of the last entry on the previous page."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Participation history",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlayerEventHistory"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not signed in",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/player/teams": {
"get": {
"tags": [
@@ -17139,6 +17204,188 @@
]
}
},
+ "/api/v1/public/events": {
+ "get": {
+ "tags": [
+ "Public · Events"
+ ],
+ "summary": "The public event calendar",
+ "description": "Upcoming, live and recent events in a window, ascending by instant. An entry is one of two things and says which: a `run` is a materialised occurrence, and a `projected` entry is arithmetic past the materialisation horizon — a forecast, with nothing committed to it, which a client should draw as such. Instants are UTC and each entry carries the EVENT\\'s own timezone, because a shard-local 8pm means the shard\\'s evening to everyone reading it; the reader\\'s own zone places the entry in a month grid. Rehearsals and unlisted events are absent. Defaults to now through 31 days out; the window may span at most 92 days.",
+ "parameters": [
+ {
+ "name": "from",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "description": "Window start (ISO). Defaults to now."
+ },
+ {
+ "name": "to",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "description": "Window end (ISO). Defaults to 31 days after the start."
+ },
+ {
+ "name": "seriesId",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Restrict to one arc."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The calendar",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicEventCalendar"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad window",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ }
+ }
+ },
+ "/api/v1/public/events/series/{slug}": {
+ "get": {
+ "tags": [
+ "Public · Events"
+ ],
+ "summary": "One arc",
+ "description": "A series and the listed events in it, in the order an editor arranged them. A series with no listed events answers 404 rather than 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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The series slug."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The arc",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicEventSeries"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such arc, or nothing in it is listed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ }
+ }
+ },
+ "/api/v1/public/events/{slug}": {
+ "get": {
+ "tags": [
+ "Public · Events"
+ ],
+ "summary": "One event",
+ "description": "The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The event slug."
+ },
+ {
+ "name": "run",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Which occurrence the results are about — what an announcement's link carries, so a mail about last Friday does not open next Friday's. A run that does not belong to this event is ignored rather than refused."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The event",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicEvent"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such public event",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ }
+ }
+ },
"/api/v1/public/modules": {
"get": {
"tags": [
@@ -24562,6 +24809,37 @@
"example": "Server package version (informational)."
}
}
+ },
+ "capabilities": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "example": {
+ "type": "array",
+ "example": [
+ "events"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. 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. Treat an unknown string as absent."
+ }
+ }
}
}
}
@@ -25980,6 +26258,1243 @@
}
}
},
+ "PublicEventEntry": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "kind": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "run",
+ "projected"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "example": {
+ "type": "string",
+ "example": "run"
+ }
+ }
+ },
+ "title": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "The Yew Invasion"
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "the-yew-invasion"
+ }
+ }
+ },
+ "seriesName": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "The Yew Campaign"
+ }
+ }
+ },
+ "seriesSlug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "the-yew-campaign"
+ }
+ }
+ },
+ "scheduledFor": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "description": {
+ "type": "string",
+ "example": "The instant, UTC."
+ }
+ }
+ },
+ "timezone": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "America/New_York"
+ },
+ "description": {
+ "type": "string",
+ "example": "The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid."
+ }
+ }
+ },
+ "status": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "scheduled",
+ "live",
+ "completed",
+ "cancelled"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event."
+ }
+ }
+ },
+ "live": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "adjusted": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Projections only: this instant is not the wall clock the schedule names, because a DST change moved it."
+ }
+ }
+ },
+ "shiftMinutes": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "Projections only: by how much."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicEventCalendar": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "window": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "to": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "entries": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/PublicEventEntry"
+ }
+ }
+ },
+ "truncated": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "The window held more entries than the cap."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicEventOccurrence": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while \"the event is running\" is the fact about the event."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "runId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 3692
+ }
+ }
+ },
+ "scheduledFor": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "timezone": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "America/New_York"
+ }
+ }
+ },
+ "startedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "endedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "status": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "scheduled",
+ "live",
+ "completed",
+ "cancelled"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "live": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ }
+ }
+ },
+ "scope": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them."
+ }
+ }
+ },
+ "phase": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "The assault"
+ },
+ "description": {
+ "type": "string",
+ "example": "The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published."
+ }
+ }
+ },
+ "resultsPublishedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicEventParticipant": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "Aldric"
+ }
+ }
+ },
+ "score": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "number"
+ },
+ "example": {
+ "type": "number",
+ "example": 1420
+ }
+ }
+ },
+ "rank": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 3
+ },
+ "description": {
+ "type": "string",
+ "example": "Null until results were published."
+ }
+ }
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Module-opaque: whatever it wanted shown beside a name."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicEvent": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "event": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "The Yew Invasion"
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "the-yew-invasion"
+ }
+ }
+ },
+ "summary": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "body": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "The storyline. Sanitized HTML, the treatment a wiki page gets."
+ }
+ }
+ },
+ "imageUrl": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "timezone": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "America/New_York"
+ }
+ }
+ },
+ "series": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "live": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ }
+ }
+ },
+ "current": {
+ "$ref": "#/components/schemas/PublicEventOccurrence"
+ },
+ "next": {
+ "$ref": "#/components/schemas/PublicEventOccurrence"
+ },
+ "upcoming": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/PublicEventOccurrence"
+ }
+ }
+ },
+ "past": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/PublicEventOccurrence"
+ }
+ }
+ },
+ "results": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "runId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ }
+ }
+ },
+ "scheduledFor": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "publishedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "participants": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/PublicEventParticipant"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicEventSeries": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "series": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "The Yew Campaign"
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "the-yew-campaign"
+ }
+ }
+ },
+ "description": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "events": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "The listed events in the arc, in the order an editor arranged them."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "summary": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "imageUrl": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PlayerEventHistory": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "entries": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "The participation row. Pass the last one as `before` to page."
+ }
+ }
+ },
+ "runId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ }
+ }
+ },
+ "title": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "slug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "seriesName": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "seriesSlug": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "scheduledFor": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "startedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "endedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "timezone": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "status": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "scheduled",
+ "live",
+ "completed",
+ "cancelled"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "joinedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "score": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "number"
+ }
+ }
+ },
+ "rank": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Null until results were published — a real state rather than an error."
+ }
+ }
+ },
+ "resultsPublishedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "meta": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"PublicTeamMember": {
"type": "object",
"properties": {
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index d804449..0551567 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -991,6 +991,13 @@ const doc = {
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
+ capabilities: {
+ type: 'array',
+ items: { type: 'string' },
+ example: ['events'],
+ description:
+ 'Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. 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. Treat an unknown string as absent.',
+ },
},
},
PublicModules: {
@@ -1234,6 +1241,190 @@ const doc = {
},
},
},
+ PublicEventEntry: {
+ type: 'object',
+ description:
+ 'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
+ properties: {
+ kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
+ title: { type: 'string', example: 'The Yew Invasion' },
+ slug: { type: 'string', example: 'the-yew-invasion' },
+ seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
+ seriesSlug: { type: 'string', nullable: true, example: 'the-yew-campaign' },
+ scheduledFor: { type: 'string', format: 'date-time', description: 'The instant, UTC.' },
+ timezone: {
+ type: 'string',
+ example: 'America/New_York',
+ description:
+ 'The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid.',
+ },
+ status: {
+ type: 'string',
+ enum: ['scheduled', 'live', 'completed', 'cancelled'],
+ description:
+ 'The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event.',
+ },
+ live: { type: 'boolean', example: false },
+ adjusted: {
+ type: 'boolean',
+ description: 'Projections only: this instant is not the wall clock the schedule names, because a DST change moved it.',
+ },
+ shiftMinutes: { type: 'integer', description: 'Projections only: by how much.' },
+ },
+ },
+ PublicEventCalendar: {
+ type: 'object',
+ properties: {
+ ok: { type: 'boolean', example: true },
+ window: {
+ type: 'object',
+ properties: {
+ from: { type: 'string', format: 'date-time' },
+ to: { type: 'string', format: 'date-time' },
+ },
+ },
+ entries: { type: 'array', items: { $ref: '#/components/schemas/PublicEventEntry' } },
+ truncated: { type: 'boolean', description: 'The window held more entries than the cap.', example: false },
+ },
+ },
+ PublicEventOccurrence: {
+ type: 'object',
+ description:
+ 'One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while "the event is running" is the fact about the event.',
+ properties: {
+ runId: { type: 'integer', example: 3692 },
+ scheduledFor: { type: 'string', format: 'date-time' },
+ timezone: { type: 'string', example: 'America/New_York' },
+ startedAt: { type: 'string', format: 'date-time', nullable: true },
+ endedAt: { type: 'string', format: 'date-time', nullable: true },
+ status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
+ live: { type: 'boolean' },
+ scope: {
+ type: 'string',
+ nullable: true,
+ description: 'Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them.',
+ },
+ phase: {
+ type: 'string',
+ nullable: true,
+ example: 'The assault',
+ description:
+ 'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
+ },
+ resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
+ },
+ },
+ PublicEventParticipant: {
+ type: 'object',
+ description:
+ 'A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`.',
+ properties: {
+ name: { type: 'string', nullable: true, example: 'Aldric' },
+ score: { type: 'number', example: 1420 },
+ rank: { type: 'integer', nullable: true, example: 3, description: 'Null until results were published.' },
+ meta: { type: 'object', nullable: true, description: 'Module-opaque: whatever it wanted shown beside a name.' },
+ },
+ },
+ PublicEvent: {
+ type: 'object',
+ properties: {
+ ok: { type: 'boolean', example: true },
+ event: {
+ type: 'object',
+ properties: {
+ title: { type: 'string', example: 'The Yew Invasion' },
+ slug: { type: 'string', example: 'the-yew-invasion' },
+ summary: { type: 'string', nullable: true },
+ body: { type: 'string', nullable: true, description: 'The storyline. Sanitized HTML, the treatment a wiki page gets.' },
+ imageUrl: { type: 'string', nullable: true },
+ timezone: { type: 'string', example: 'America/New_York' },
+ series: {
+ type: 'object',
+ nullable: true,
+ properties: { name: { type: 'string' }, slug: { type: 'string' } },
+ },
+ live: { type: 'boolean' },
+ current: { $ref: '#/components/schemas/PublicEventOccurrence' },
+ next: { $ref: '#/components/schemas/PublicEventOccurrence' },
+ upcoming: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
+ past: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
+ results: {
+ type: 'object',
+ nullable: true,
+ description:
+ 'Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any.',
+ properties: {
+ runId: { type: 'integer' },
+ scheduledFor: { type: 'string', format: 'date-time' },
+ publishedAt: { type: 'string', format: 'date-time' },
+ participants: { type: 'array', items: { $ref: '#/components/schemas/PublicEventParticipant' } },
+ },
+ },
+ },
+ },
+ },
+ },
+ PublicEventSeries: {
+ type: 'object',
+ properties: {
+ ok: { type: 'boolean', example: true },
+ series: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', example: 'The Yew Campaign' },
+ slug: { type: 'string', example: 'the-yew-campaign' },
+ description: { type: 'string', nullable: true },
+ events: {
+ type: 'array',
+ description: 'The listed events in the arc, in the order an editor arranged them.',
+ items: {
+ type: 'object',
+ properties: {
+ title: { type: 'string' },
+ slug: { type: 'string' },
+ summary: { type: 'string', nullable: true },
+ imageUrl: { type: 'string', nullable: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ PlayerEventHistory: {
+ type: 'object',
+ properties: {
+ ok: { type: 'boolean', example: true },
+ entries: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'integer', description: 'The participation row. Pass the last one as `before` to page.' },
+ runId: { type: 'integer' },
+ title: { type: 'string' },
+ slug: { type: 'string' },
+ seriesName: { type: 'string', nullable: true },
+ seriesSlug: { type: 'string', nullable: true },
+ scheduledFor: { type: 'string', format: 'date-time' },
+ startedAt: { type: 'string', format: 'date-time', nullable: true },
+ endedAt: { type: 'string', format: 'date-time', nullable: true },
+ timezone: { type: 'string' },
+ status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
+ joinedAt: { type: 'string', format: 'date-time' },
+ score: { type: 'number' },
+ rank: {
+ type: 'integer',
+ nullable: true,
+ description: 'Null until results were published — a real state rather than an error.',
+ },
+ resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
+ meta: { type: 'object', nullable: true },
+ },
+ },
+ },
+ },
+ },
PublicTeamMember: {
type: 'object',
description:
diff --git a/server/test/eventAnnounce.test.js b/server/test/eventAnnounce.test.js
index 306f010..cab381c 100644
--- a/server/test/eventAnnounce.test.js
+++ b/server/test/eventAnnounce.test.js
@@ -283,21 +283,40 @@ test('six are ceilinged authenticated; run.failed is admin on both halves', () =
}
})
-test('no public event trigger declares a url — there is no page for one to point at yet', () => {
- // `news.post` shipped an example naming `/news/`, a path that does not
- // exist, and the template editor previewed a link that was dead in every mail
- // it sent. Phase 14 adds the variable alongside the page.
+test('every public event trigger points at the page Phase 14a built, and run.failed at the console', () => {
+ // The inverse of what this asserted from Phase 10 until Phase 14a, and the
+ // inversion is the point: `news.post` shipped an example naming `/news/`,
+ // a path that did not exist, so the template editor previewed a link that was
+ // dead in every mail it sent. The variable was withheld until there was a page,
+ // and it arrived with it.
for (const t of eventTriggers()) {
const urls = t.variables.filter((v) => v.type === 'url')
if (t.id === 'event.run.failed') {
+ // No `eventUrl` here, deliberately: an admin reading that the machinery
+ // broke wants the steps and the errors, not the storyline.
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
} else {
- assert.deepEqual(urls, [], `${t.id} must declare no url until Phase 14`)
+ assert.deepEqual(urls.map((v) => v.name), ['eventUrl'], `${t.id}`)
+ // The example has to carry `?run=`, because that is what makes a link in a
+ // mail about last Friday open last Friday rather than next Friday.
+ assert.match(urls[0].example, /^\/site\/events\/[^?]+\?run=/, `${t.id}`)
+ // Optional, so `email.button` drops itself rather than rendering an inert
+ // grey label when the event is not public and there is no page.
+ assert.equal(urls[0].required, false, `${t.id}`)
}
}
})
+test('the six public triggers are at version 2 — the url variable is a declaration change', () => {
+ // A variable added to a declaration is a version bump, not a correction: a rule
+ // written against version 1 was written against a payload with no link in it.
+ // `run.failed` gained nothing and stays where it was.
+ for (const t of eventTriggers()) {
+ assert.equal(t.version, t.id === 'event.run.failed' ? 1 : 2, `${t.id}`)
+ }
+})
+
test('every declared variable carries an example, which is what the editor previews with', () => {
for (const t of eventTriggers()) {
for (const v of t.variables) {
diff --git a/server/test/eventPublic.test.js b/server/test/eventPublic.test.js
new file mode 100644
index 0000000..17119c5
--- /dev/null
+++ b/server/test/eventPublic.test.js
@@ -0,0 +1,417 @@
+// ── The public event surface (EVENTS_PLAN.md Phase 14a) ────────────────────
+//
+// The phase's shipped claim: **a visitor with no account sees the calendar, one
+// event's page and an arc, and sees nothing an operator did not announce.**
+//
+// What is worth testing here is almost entirely the second half. The reads
+// themselves are joins; the decisions are about what is absent:
+//
+// • a rehearsal, an unlisted definition and a draft are absent from every
+// surface, and absent the same way — a 404 that cannot be told from a slug
+// that never existed
+// • the plan behind an event (phases, steps, actions, params) is never
+// published; a live run carries the LABEL of its phase and nothing else
+// • `failed` and `missed` are published as `cancelled`, because the difference
+// between them is about the deployment rather than about the event
+// • `member_key` never leaves the server, even on a results table
+// • a participant's own history obeys the same two exclusions as the calendar,
+// so attending an unannounced event does not disclose that it exists
+//
+// Stubbed at the `.db` layer, the shape `eventSchedule.test.js` uses.
+
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const publicModel = require('../src/model/events/eventPublic.model')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
+const runsDb = require('../src/model/events/eventRuns.db')
+const seriesDb = require('../src/model/events/eventSeries.db')
+const versionsDb = require('../src/model/events/eventVersions.db')
+const participantsDb = require('../src/model/events/eventRunParticipants.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const NOW = new Date('2026-09-01T12:00:00Z')
+
+const SPEC = {
+ schedule: { kind: 'manual' },
+ phases: [
+ { id: 'muster', label: 'The muster', steps: [{ id: 's1', action: 'core.announce' }] },
+ { id: 'assault', label: 'The assault', steps: [] },
+ ],
+}
+
+const originals = {}
+for (const [name, mod] of [
+ ['definitionsDb', definitionsDb],
+ ['runsDb', runsDb],
+ ['seriesDb', seriesDb],
+ ['versionsDb', versionsDb],
+ ['participantsDb', participantsDb],
+]) {
+ originals[name] = { mod, fns: { ...mod } }
+}
+const restoreOriginals = () => {
+ for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
+}
+
+let store
+
+const definition = (over = {}) => ({
+ id: 1,
+ title: 'The Yew Invasion',
+ slug: 'the-yew-invasion',
+ summary: 'Orcish warbands are massing north of Yew.',
+ body: '
They came at dusk.
',
+ image_url: null,
+ state: 'ready',
+ listed: true,
+ timezone: 'America/New_York',
+ series_id: null,
+ series_name: null,
+ series_slug: null,
+ current_version_id: 100,
+ spec: SPEC,
+ ...over,
+})
+
+const run = (over = {}) => ({
+ id: 3692,
+ definition_id: 1,
+ version_id: 100,
+ scope: '',
+ status: 'completed',
+ // Every one of these is a field the public shapes must NOT carry. They are on
+ // the fixture on purpose: a `{ ...run }` anywhere in the model would publish
+ // them, and the assertions below are what would catch it.
+ health: 'degraded',
+ cleanup_status: 'incomplete',
+ claimed_by: 'worker-3',
+ claim_expires_at: new Date(),
+ last_error: 'sidecar responded 503',
+ current_phase: 'assault',
+ scheduled_for: new Date('2026-08-29T00:00:00Z'),
+ started_at: new Date('2026-08-29T00:00:05Z'),
+ ended_at: new Date('2026-08-29T01:30:00Z'),
+ timezone: 'America/New_York',
+ rehearsal: false,
+ results_published_at: new Date('2026-08-29T02:00:00Z'),
+ ...over,
+})
+
+function installStubs() {
+ definitionsDb.getPublicBySlug = async (slug) => {
+ const d = store.definitions.find((x) => x.slug === slug)
+ return d && d.state === 'ready' && d.listed ? d : undefined
+ }
+ definitionsDb.listPublicBySeries = async (seriesId) =>
+ store.definitions.filter((d) => d.series_id === seriesId && d.state === 'ready' && d.listed)
+ definitionsDb.findSchedulable = async ({ listedOnly = false } = {}) =>
+ store.definitions
+ .filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
+ .map((d) => ({ ...d, version_spec: d.spec }))
+
+ runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
+ store.runs.filter((r) => {
+ const at = new Date(r.scheduled_for)
+ if (at < from || at >= to) return false
+ if (!publicOnly) return true
+ const d = store.definitions.find((x) => x.id === r.definition_id)
+ return !r.rehearsal && d && d.listed && d.state !== 'archived'
+ })
+ runsDb.listPublicForDefinition = async (id) =>
+ store.runs
+ .filter((r) => r.definition_id === id && !r.rehearsal)
+ .sort((a, b) => new Date(b.scheduled_for) - new Date(a.scheduled_for))
+
+ seriesDb.getById = async (id) => store.series.find((s) => s.id === id) || null
+ seriesDb.getBySlug = async (slug) => store.series.find((s) => s.slug === slug) || null
+ versionsDb.getById = async (id) => (id === 100 ? { id, spec: SPEC } : null)
+ participantsDb.listForRun = async () => store.participants
+ participantsDb.listForUser = async () => store.history
+}
+
+beforeEach(() => {
+ const d = definition()
+ store = {
+ definitions: [d],
+ // The joined shape `listInWindow` answers with.
+ runs: [{ ...run(), definition_title: d.title, definition_slug: d.slug }],
+ series: [],
+ participants: [],
+ history: [],
+ }
+ installStubs()
+})
+afterEach(restoreOriginals)
+
+// ── The calendar ───────────────────────────────────────────────────────────
+
+test('a calendar entry carries no operational field at all', async () => {
+ const result = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
+ assert.equal(result.ok, true)
+ const [entry] = result.entries
+ assert.equal(entry.title, 'The Yew Invasion')
+ // The whole security property of this file, asserted positively: the entry has
+ // exactly these keys and gaining one is a deliberate act.
+ assert.deepEqual(Object.keys(entry).sort(), [
+ 'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
+ ])
+})
+
+test('the calendar defaults to a month from now when no window is given', async () => {
+ const result = await publicModel.calendar({ now: NOW })
+ assert.equal(result.ok, true)
+ assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
+ const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
+ assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
+})
+
+test('a window wider than the cap is refused rather than served slowly', async () => {
+ const result = await publicModel.calendar({ from: '2026-01-01', to: '2026-12-31', now: NOW })
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 400)
+})
+
+test('a projection is not emitted for an instant a run already occupies', async () => {
+ // The definition recurs weekly on the Saturday its one run already sits on.
+ store.definitions[0].spec = {
+ ...SPEC,
+ schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
+ }
+ const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-08-31', now: NOW })
+ const at = result.entries.filter(
+ (e) => new Date(e.scheduledFor).getTime() === new Date('2026-08-29T00:00:00Z').getTime(),
+ )
+ assert.equal(at.length, 1)
+ assert.equal(at[0].kind, 'run')
+})
+
+// ── What the public never sees ─────────────────────────────────────────────
+
+test('an unlisted event is absent from the calendar and 404s on its own page', async () => {
+ store.definitions[0].listed = false
+ const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
+ assert.deepEqual(cal.entries, [])
+
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.ok, false)
+ assert.equal(page.status, 404)
+})
+
+test('a draft answers exactly as an unlisted one does — indistinguishable from no such slug', async () => {
+ store.definitions[0].state = 'draft'
+ const draft = await publicModel.event('the-yew-invasion')
+ const missing = await publicModel.event('no-such-event')
+ assert.deepEqual(draft, missing)
+})
+
+test('a rehearsal is absent from the calendar and from an event page', async () => {
+ store.runs[0].rehearsal = true
+ const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
+ assert.deepEqual(cal.entries, [])
+
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.ok, true)
+ assert.deepEqual(page.event.past, [])
+})
+
+test('an occurrence publishes no health, no cleanup state, no claim and no error', async () => {
+ store.runs[0].status = 'running'
+ const page = await publicModel.event('the-yew-invasion')
+ const occurrence = page.event.current
+ for (const leaked of ['health', 'cleanupStatus', 'claimedBy', 'claimExpiresAt', 'lastError', 'versionId']) {
+ assert.equal(occurrence[leaked], undefined, `${leaked} must not be published`)
+ }
+ assert.equal(JSON.stringify(page).includes('sidecar responded 503'), false)
+})
+
+test('a live run carries its phase LABEL, and never the spec behind it', async () => {
+ store.runs[0].status = 'running'
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.current.phase, 'The assault')
+ // The step ids in the fixture spec are the tell: if the spec were published
+ // anywhere in this answer, this would find it.
+ assert.equal(JSON.stringify(page).includes('core.announce'), false)
+})
+
+test('a phase the pinned version does not name renders nothing rather than an id', async () => {
+ store.runs[0].status = 'running'
+ store.runs[0].current_phase = 'a-phase-since-renamed'
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.current.phase, null)
+})
+
+test('failed and missed are both published as cancelled', async () => {
+ for (const status of ['failed', 'missed']) {
+ store.runs[0].status = status
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.past[0].status, 'cancelled', status)
+ }
+})
+
+test('paused is published as live — an operator holding a run is not a public state', async () => {
+ store.runs[0].status = 'paused'
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.current.status, 'live')
+ assert.equal(page.event.live, true)
+})
+
+// ── Which side of now an occurrence falls on ───────────────────────────────
+//
+// Both of these were found by the browser walk, and both are the same mistake:
+// the split reading a STATUS where it should read a clock. Dates here are
+// relative to the real clock, because `event()` asks `Date.now()` — a run
+// "next Friday" has to still be next Friday when this runs.
+
+const inDays = (n) => new Date(Date.now() + n * 86_400_000)
+
+test('a cancelled occurrence in the FUTURE is what is coming, not what happened', async () => {
+ // It was announced and it has been called off, and "next Friday is off" is
+ // exactly what somebody checking the calendar came to find out. Filing it
+ // under "previously" tells them it already happened, which is the one thing
+ // certainly untrue about it.
+ store.runs = [
+ { ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
+ { ...run({ id: 2, status: 'scheduled', scheduled_for: inDays(11), ended_at: null, results_published_at: null }) },
+ ]
+ const page = await publicModel.event('the-yew-invasion')
+ assert.deepEqual(page.event.upcoming.map((o) => o.runId), [1, 2])
+ assert.deepEqual(page.event.past, [])
+})
+
+test('`next` skips a cancelled occurrence even though it is listed as coming', async () => {
+ // The headline answers "when is the next one", and a cancelled occurrence is
+ // not one. An event whose only future occurrence was called off has no `next`
+ // and says so, while the cancellation is still listed below.
+ store.runs = [
+ { ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
+ ]
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.next, null)
+ assert.equal(page.event.upcoming.length, 1)
+})
+
+test('a scheduled occurrence whose moment has gone by is in the past', async () => {
+ // The other direction of the same rule: the runner had not reached it, so its
+ // status still says `scheduled` while the evening it named is over.
+ store.runs = [
+ { ...run({ id: 1, status: 'scheduled', scheduled_for: inDays(-3), ended_at: null, results_published_at: null }) },
+ ]
+ const page = await publicModel.event('the-yew-invasion')
+ assert.deepEqual(page.event.upcoming, [])
+ assert.deepEqual(page.event.past.map((o) => o.runId), [1])
+})
+
+// ── Results ────────────────────────────────────────────────────────────────
+
+test('a results row publishes the score and the rank and never the member key', async () => {
+ store.participants = [
+ { id: 1, member_key: 'serial:0x40001234', user_id: 7, score: 1420, rank_at: 1, meta: { name: 'Aldric' } },
+ ]
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.results.participants[0].name, 'Aldric')
+ assert.equal(page.event.results.participants[0].rank, 1)
+ assert.equal(JSON.stringify(page).includes('0x40001234'), false)
+ assert.equal(JSON.stringify(page).includes('user_id'), false)
+})
+
+test('an unpublished results table is absent rather than empty', async () => {
+ store.runs[0].results_published_at = null
+ store.participants = [{ id: 1, member_key: 'k', score: 10, rank_at: null, meta: null }]
+ const page = await publicModel.event('the-yew-invasion')
+ assert.equal(page.event.results, null)
+})
+
+test('?run= selects which occurrence the results are about', async () => {
+ const older = {
+ ...run({ id: 3600, scheduled_for: new Date('2026-08-22T00:00:00Z') }),
+ definition_title: 'The Yew Invasion',
+ definition_slug: 'the-yew-invasion',
+ }
+ store.runs.push(older)
+ const page = await publicModel.event('the-yew-invasion', { runId: '3600' })
+ assert.equal(page.event.results.runId, 3600)
+})
+
+test('a run id belonging to no occurrence of this event renders the page anyway', async () => {
+ // A stale link in a months-old mail should land on the event it was about, not
+ // on a dead end.
+ const page = await publicModel.event('the-yew-invasion', { runId: '999999' })
+ assert.equal(page.ok, true)
+ assert.equal(page.event.slug, 'the-yew-invasion')
+})
+
+// ── The arc ────────────────────────────────────────────────────────────────
+
+test('a series with nothing listed in it is a 404, not an empty page', async () => {
+ store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: null }]
+ store.definitions[0].series_id = 5
+ store.definitions[0].listed = false
+ const arc = await publicModel.series('the-yew-campaign')
+ assert.equal(arc.ok, false)
+ assert.equal(arc.status, 404)
+})
+
+test('an arc lists its listed events and nothing about their plans', async () => {
+ store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: 'An arc.' }]
+ store.definitions[0].series_id = 5
+ const arc = await publicModel.series('the-yew-campaign')
+ assert.equal(arc.ok, true)
+ assert.deepEqual(Object.keys(arc.series.events[0]).sort(), ['imageUrl', 'slug', 'summary', 'title'])
+})
+
+// ── Participation history ──────────────────────────────────────────────────
+
+test('history publishes the rank as null until results were published', async () => {
+ store.history = [
+ {
+ id: 9,
+ run_id: 3692,
+ score: 1420,
+ rank_at: null,
+ joined_at: new Date(),
+ meta: null,
+ scheduled_for: new Date('2026-08-29T00:00:00Z'),
+ started_at: null,
+ ended_at: null,
+ status: 'completed',
+ scope: '',
+ timezone: 'UTC',
+ results_published_at: null,
+ definition_title: 'The Yew Invasion',
+ definition_slug: 'the-yew-invasion',
+ series_name: null,
+ series_slug: null,
+ },
+ ]
+ const result = await publicModel.history(7)
+ assert.equal(result.entries[0].rank, null)
+ assert.equal(result.entries[0].resultsPublishedAt, null)
+ assert.equal(result.entries[0].score, 1420)
+})
+
+test('history publishes no member key', async () => {
+ store.history = [
+ {
+ id: 9,
+ run_id: 3692,
+ member_key: 'serial:0x40001234',
+ score: 1,
+ rank_at: 1,
+ joined_at: new Date(),
+ meta: null,
+ scheduled_for: new Date(),
+ status: 'completed',
+ timezone: 'UTC',
+ definition_title: 't',
+ definition_slug: 's',
+ },
+ ]
+ const result = await publicModel.history(7)
+ assert.equal(JSON.stringify(result).includes('0x40001234'), false)
+})
--
2.49.1
From eb167558e39368706c29d0d830c7b3b62dca8f69 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 8 Sep 2026 11:59:03 -0500
Subject: [PATCH 15/18] fix(events): staff could not reach their own
participation history
Found by the live walk, signed in as an admin: /account/events redirected to
the dashboard. `GET /player/events/history` is behind requireAuth alone and
self-scoped on req.user.id -- staff are a superset of players -- but the WEB
has two logged-in shells, and RequirePlayer sends anyone who is not a
`player` out of /account. A single mount there is a screen the reviewing
admin can never open.
Engagement Phase 7 hit this exact wall with the inbox and answered it with
two routes, one pair of components and one mapping. `eventHistoryPath` joins
`inboxPath` and `notificationSettingsPath` in notificationPaths.js rather
than starting a second file with the same comment at the top of it. The
staff path is /admin/events/mine, in the Events section of the sidebar, and
it is the one row in that group with no `roles`.
Also: the eventAnnounce fixture carried no slug, state or `listed`, so
`eventUrl` answered undefined in every test in that file and the new code
was exercised by none of them. The fixture now looks like a definition row,
and three tests cover the link, the unlisted case and the draft case.
The run.failed assertion that came with them was reading the wrong layer:
`baseFor` assembles eventUrl for every trigger and the SEAM drops the keys a
trigger does not declare, so the declaration test is what proves it. Removed,
with a note saying where the rule actually lives.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
---
client/src/App.jsx | 9 +++++++
client/src/lib/notificationPaths.js | 14 ++++++++++
client/src/routes/admin/AdminLayout.jsx | 7 +++++
server/test/eventAnnounce.test.js | 36 +++++++++++++++++++++++++
4 files changed, 66 insertions(+)
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 74db2f0..aac4eee 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -262,6 +262,15 @@ export default function App() {
two paths; `lib/notificationPaths.js` is the one mapping. */}
} />
} />
+ {/* And participation history, for the same reason and by the same
+ arrangement (Phase 14a): `/player/events/history` is behind
+ requireAuth alone, so a staff member has one — but
+ `RequirePlayer` sends them out of `/account`. Declared BEFORE
+ `events/:id`, though it need not be: a static segment outranks
+ a dynamic one whatever the order, which is the rule that made
+ `events/new` unreachable for seven phases. Written in the order
+ it resolves. */}
+ } />
{/* Installed modules' admin pages, at /admin//…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
diff --git a/client/src/lib/notificationPaths.js b/client/src/lib/notificationPaths.js
index f571447..48e378d 100644
--- a/client/src/lib/notificationPaths.js
+++ b/client/src/lib/notificationPaths.js
@@ -19,3 +19,17 @@ export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/a
/** The per-channel preferences screen. */
export const notificationSettingsPath = (user) =>
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
+
+/**
+ * This account's own event participation (events Phase 14a).
+ *
+ * The third screen to need this mapping, and it needed it for exactly the reason
+ * the two above did: `GET /player/events/history` is behind `requireAuth` alone,
+ * self-scoped on `req.user.id` — a staff member has a participation history like
+ * anyone else, and the group's own header says staff are a superset of players.
+ * The WEB is what disagrees, because `RequirePlayer` sends them to the login
+ * page. Found the same way the notifications pair was: signed in as an admin,
+ * the screen simply redirected.
+ */
+export const eventHistoryPath = (user) =>
+ isStaff(user) ? '/admin/events/mine' : '/account/events'
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 80c1db1..452611a 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -142,6 +142,12 @@ export const NAV = [
// governs: what a deployment permits at all is configuration, not a read,
// and the server gates both the GET and the PUT on `admin`.
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
+ // Phase 14a, and the one row here that is not about running the
+ // deployment: it is this staff member's OWN attendance, the same screen
+ // and the same route a player reads at /account/events. It has no `roles`
+ // because it needs none — every account has a participation history, and
+ // the server scopes it to the caller.
+ { to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
],
},
{
@@ -229,6 +235,7 @@ const TITLES = {
'/admin/events': 'Events',
'/admin/events/calendar': 'Event calendar',
'/admin/events/actions': 'Event actions',
+ '/admin/events/mine': 'My participation',
'/admin/events/new': 'New event',
}
diff --git a/server/test/eventAnnounce.test.js b/server/test/eventAnnounce.test.js
index cab381c..bb72fc3 100644
--- a/server/test/eventAnnounce.test.js
+++ b/server/test/eventAnnounce.test.js
@@ -45,9 +45,16 @@ after(() => db.close())
const DEFINITION = {
id: 3,
title: 'The Yew Invasion',
+ slug: 'the-yew-invasion',
summary: 'Orcish warbands are massing north of Yew.',
series_name: 'The Yew Campaign',
timezone: 'America/New_York',
+ // Both are load-bearing for `eventUrl` (Phase 14a): an event with no public
+ // page gets no link. They were absent from this fixture, which meant the url
+ // was undefined in every test here and the new code was exercised by none of
+ // them.
+ state: 'ready',
+ listed: true,
}
const RUN = {
@@ -201,6 +208,12 @@ test('run.cancelled carries the operator\'s reason, and omits it when none was g
assert.equal(only().envelope.data.reason, undefined)
})
+// `run.failed` alone gets no public page, and the DECLARATION is what enforces
+// that rather than anything here: `baseFor` assembles `eventUrl` for every
+// trigger and the seam drops the keys a trigger does not declare. The test above
+// that asserts run.failed's url variables are exactly `['runUrl']` is therefore
+// the one that proves it — an assertion on this envelope would be reading the
+// wrong layer, because the filtering has not happened yet at this point.
test('run.failed links the run console — the one destination that exists today', async () => {
await announce.runFailed(RUN, 'sidecar responded 503')
const { data } = only().envelope
@@ -209,6 +222,29 @@ test('run.failed links the run console — the one destination that exists today
assert.equal(data.runUrl, '/admin/events/runs/3692')
})
+test('every public emit carries the page for THIS occurrence', async () => {
+ await announce.runStarted(RUN)
+ // The slug is the definition's and the run is in the query string. Without
+ // `?run=` a mail about last Friday's occurrence would open next Friday's.
+ assert.equal(only().envelope.data.eventUrl, '/site/events/the-yew-invasion?run=3692')
+})
+
+test('an UNLISTED event announces with no link rather than a link that 404s', async () => {
+ // `eventUrl` is declared optional exactly so `email.button` can drop itself.
+ // A path here would render as a dead button in every mail — worse than none,
+ // because it advertises a link the reader cannot follow. `news.post` paid for
+ // that once already.
+ definitionsDb.getById = async () => ({ ...DEFINITION, listed: false })
+ await announce.runStarted(RUN)
+ assert.equal(only().envelope.data.eventUrl, undefined)
+})
+
+test('a definition that is not yet `ready` has no page either', async () => {
+ definitionsDb.getById = async () => ({ ...DEFINITION, state: 'draft' })
+ await announce.runStarted(RUN)
+ assert.equal(only().envelope.data.eventUrl, undefined)
+})
+
test('run.failed falls back to the run\'s own last error', async () => {
await announce.runFailed({ ...RUN, last_error: 'the pinned version has no phases' }, null)
assert.equal(only().envelope.data.error, 'the pinned version has no phases')
--
2.49.1
From e46842a28c6c0de732493163f4ce36b5eb9debc9 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 8 Sep 2026 18:48:57 -0500
Subject: [PATCH 16/18] fix(events): carry a module's own account of a
successful step
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.
`module-uo` believed it, twice, since Phase 12b:
* `uo.item.grant` answers `{ granted, missed, why }`
* `uo.world.save` answers `{ started: true }`
The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.
Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.
## What this adds
`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.
**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.
* objects only — the column is JSON and the console renders keys, so a bare
string has nothing to render under, and core inventing a key would be core
interpreting it after all;
* 4KB of serialised JSON, dropped rather than truncated, because half a JSON
object is not a JSON object;
* unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
runner would make the log INSERT throw, inside the one write documented
never to;
* re-parsed rather than passed through, so core holds no live reference into
a module's object;
* **anything wrong with it is dropped and logged, never a failure.** A step
that did what it was asked must not be re-run because its module's
commentary was malformed: that is a world write repeated for a log line.
The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.
## The renderer, which is half the fix
`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.
uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true
**`module-uo` needs no change**: the code it already shipped starts working.
MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
---
client/src/lib/eventAuthoring.js | 53 +++++++++++
client/test/eventAuthoring.test.js | 48 ++++++++++
server/src/events/dispatch.js | 73 ++++++++++++++-
server/src/model/events/eventRunLog.db.js | 6 ++
server/src/utils/eventRunner.js | 21 +++++
server/test/eventRunner.test.js | 106 ++++++++++++++++++++++
6 files changed, 306 insertions(+), 1 deletion(-)
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 87c9e32..84c4bed 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -692,9 +692,46 @@ const KIND_WORDS = {
'step.refused': 'Refused',
'run.budget': 'Caps',
'version.verified': 'Dry run passed',
+ // Phase 15. "Reported" rather than "Detail": the line is the module talking
+ // about its own verb, and every other word here names something core did.
+ 'step.detail': 'Step reported',
note: 'Note',
}
+// How deep and how long a module's own `detail` value is allowed to render.
+// The dispatcher already caps the whole object at 4KB, so this is about a line
+// staying a line — an operator scanning a run's log should not have one row
+// wrap eight times because a module answered with an array of forty names.
+const DETAIL_LIST_SHOWN = 5
+const DETAIL_TEXT_MAX = 80
+
+/**
+ * One value out of a module's `detail`, as text.
+ *
+ * **Core does not interpret these keys and neither does this.** A module wrote
+ * the object; the console shows it. That is the whole reason the renderer is
+ * generic rather than a switch — a switch would be core learning a module's
+ * vocabulary, which is the thing the module system exists to prevent.
+ */
+function detailValue(value) {
+ if (value === null || value === undefined) return '—'
+ if (Array.isArray(value)) {
+ const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
+ return value.length > DETAIL_LIST_SHOWN
+ ? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
+ : shown
+ }
+ if (typeof value === 'object') {
+ // A nested object is rendered by its keys rather than as JSON: an operator
+ // reading a log wants "granted: 8, missed: 4", not a brace.
+ return Object.entries(value)
+ .map(([k, v]) => `${k} ${detailValue(v)}`)
+ .join(', ')
+ }
+ const text = String(value)
+ return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}…` : text
+}
+
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
/**
@@ -758,6 +795,22 @@ export function describeLogLine(line) {
.join(', ') || 'no caps apply to this run'
case 'version.verified':
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
+ // Phase 15. The one line whose body core did not compose: a module may answer
+ // a successful step with a `detail` object, and this renders whatever keys it
+ // put there. `action` is core's own and is pulled out to lead the sentence;
+ // everything after it is the module's.
+ //
+ // **Without this case the row would render as the literal string
+ // "step.detail"**, because the default below is a kind word and not a
+ // sentence — which would be the reporting channel existing and showing
+ // nothing, the exact failure it was built to fix.
+ case 'step.detail': {
+ const { action, ...rest } = d
+ const body = Object.entries(rest)
+ .map(([key, value]) => `${key}: ${detailValue(value)}`)
+ .join(', ')
+ return body ? `${action || 'A step'} — ${body}` : `${action || 'A step'} reported nothing`
+ }
default:
return logKindWord(line?.kind)
}
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
index 1c86248..74de846 100644
--- a/client/test/eventAuthoring.test.js
+++ b/client/test/eventAuthoring.test.js
@@ -347,6 +347,54 @@ test('the log lines a run produces all render as something', () => {
}
})
+// ── The one log line core did not compose (Phase 15) ──────────────────────
+
+test('a module detail line renders the module keys, not the kind id', () => {
+ // The failure this guards is subtle and total: `step.detail` falling to the
+ // default renders the literal string "step.detail", which is the reporting
+ // channel existing and showing nothing — exactly what it was built to fix.
+ const text = describeLogLine({
+ kind: 'step.detail',
+ detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
+ })
+
+ assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
+ assert.match(text, /uo\.item\.grant/)
+ assert.match(text, /granted: 8/)
+ assert.match(text, /missed: 4/)
+ assert.match(text, /bank full/)
+})
+
+test('a module detail is rendered generically, whatever a module puts in it', () => {
+ // Core does not interpret these keys and neither does the renderer — a switch
+ // here would be the browser learning one module vocabulary, which is the thing
+ // the module system exists to prevent. So an unfamiliar shape still reads.
+ const text = describeLogLine({
+ kind: 'step.detail',
+ detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
+ })
+ assert.ok(!text.includes('undefined'), text)
+ assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
+ assert.match(text, /eu 3/)
+ assert.match(text, /dryRun: false/, 'false is a value, not an absence')
+})
+
+test('a long module detail stays one line', () => {
+ const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
+ const text = describeLogLine({
+ kind: 'step.detail',
+ detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
+ })
+ assert.match(text, /and 35 more/)
+ assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
+})
+
+test('a module detail with nothing in it still reads as a sentence', () => {
+ const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
+ assert.ok(text.length > 0)
+ assert.ok(!text.includes('undefined'), text)
+})
+
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.ok(runStatusWord(s).length > 0)
diff --git a/server/src/events/dispatch.js b/server/src/events/dispatch.js
index f1fa8aa..4db6e5e 100644
--- a/server/src/events/dispatch.js
+++ b/server/src/events/dispatch.js
@@ -36,6 +36,71 @@ const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
// worked.
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
+// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
+// in `event_run_log.detail` and is read back by the run console, so it is a
+// diagnostic line rather than a data channel — a module with more to say than
+// this has a table of its own to say it in. Dropped rather than truncated when it
+// is over: a truncated JSON object is not a JSON object, and a console that
+// rendered half of one would be a second bug on top of the first.
+const MAX_DETAIL_BYTES = 4096
+
+/**
+ * A module's own account of what a successful step actually did.
+ *
+ * Optional, module-opaque, and **never interpreted by core** — it is carried to
+ * the run log and rendered, and nothing here or in the runner reads a key out of
+ * it. That is the whole contract: a module knows things about its own verb that
+ * core cannot compute and has no other way to say. `uo.item.grant` is the case
+ * that forced it — a grant reaches the players a run's ledger holds, and *which
+ * of them missed out* is knowable only to the module and reported nowhere else,
+ * so an operator saw a step marked `done` and never learned four of twelve got
+ * nothing.
+ *
+ * **Anything wrong with it is dropped and logged, never a failure.** A step that
+ * did what it was asked must not be re-run because its module's commentary was
+ * malformed — that would be a world write repeated for a log line. Same posture
+ * `participants` takes, and for the same reason.
+ */
+function safeDetail(detail, actionId) {
+ if (detail === undefined || detail === null) return null
+
+ // Objects only. The column is JSON and the console renders keys, so a bare
+ // string or a number has nothing to render under — and core inventing a key to
+ // put it beneath would be core interpreting it after all.
+ if (typeof detail !== 'object' || Array.isArray(detail)) {
+ log.warn('action detail is not an object', { action: actionId, type: typeof detail })
+ return null
+ }
+
+ let encoded
+ try {
+ encoded = JSON.stringify(detail)
+ } catch (err) {
+ // A circular reference, or a `toJSON` that throws. Reaching the runner would
+ // make the INSERT throw instead, inside the one write that is documented
+ // never to.
+ log.warn('action detail could not be serialised', { action: actionId, message: err.message })
+ return null
+ }
+ if (encoded === undefined) {
+ log.warn('action detail serialised to nothing', { action: actionId })
+ return null
+ }
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
+ log.warn('action detail is too large', {
+ action: actionId,
+ bytes: Buffer.byteLength(encoded, 'utf8'),
+ max: MAX_DETAIL_BYTES,
+ })
+ return null
+ }
+
+ // Re-parsed rather than passed through, so what the runner writes is a plain
+ // JSON value with no getters, no prototype and no live reference into whatever
+ // the module still holds.
+ return JSON.parse(encoded)
+}
+
/**
* Run `fn()` under a deadline.
*
@@ -103,6 +168,7 @@ function classify(result, actionId) {
error: null,
resources: result.resources || [],
participants: result.participants || [],
+ detail: safeDetail(result.detail, actionId),
}
}
@@ -125,6 +191,11 @@ function classify(result, actionId) {
holdSeconds,
resources: result.resources || [],
participants: result.participants || [],
+ // On the same two success shapes as `resources` and `participants`, and for
+ // the same reason: `await: 'human'` is a success, and a cue's confirm
+ // finishes the step without a second dispatch, so this is the only moment
+ // its module could ever have said anything.
+ detail: safeDetail(result.detail, actionId),
}
}
@@ -179,4 +250,4 @@ async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
return classification
}
-module.exports = { dispatchStep, classify, withDeadline, OUTCOMES, MAX_HOLD_SECONDS }
+module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }
diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js
index 19486a1..c5b4e0d 100644
--- a/server/src/model/events/eventRunLog.db.js
+++ b/server/src/model/events/eventRunLog.db.js
@@ -65,6 +65,12 @@ const KINDS = [
'results.published', // the results table was ranked and stamped
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
'announcement.enqueued', // a post was linked to this run and queued on the legs
+ // Phase 15's one, and it is the only kind whose payload core does not compose.
+ // A module may answer a success envelope with a `detail` object; it is bounded
+ // and sanitised at the dispatcher and written here verbatim beside the action
+ // id. Nothing reads a key out of it — it exists because a module knows things
+ // about its own verb that core cannot compute and had no other way to say.
+ 'step.detail', // a module's own account of what a successful step did
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
diff --git a/server/src/utils/eventRunner.js b/server/src/utils/eventRunner.js
index 4ebffe1..fdd1da5 100644
--- a/server/src/utils/eventRunner.js
+++ b/server/src/utils/eventRunner.js
@@ -361,6 +361,27 @@ async function drainStep(run, step, now, carry = {}) {
},
})
}
+
+ // **What the module has to say about it**, which is the third thing a
+ // success envelope can carry and the only one core does not interpret.
+ // `resources` is what to undo and `participants` is who took part; this is
+ // everything else the module knows and core cannot compute — how many of a
+ // run's players a grant actually reached, whether a save had already started.
+ // Bounded and sanitised in `dispatch.js`; by here it is a plain JSON object
+ // or null.
+ //
+ // Its own line rather than a field on one of the two above, because a step
+ // very often has this and neither of those — the grant that forced it
+ // ledgers nothing (`reversible: 'none'`) and reports no participants.
+ if (result.detail) {
+ await logDb.write({
+ runId: run.id,
+ stepId: step.id,
+ kind: 'step.detail',
+ phase: step.phase,
+ detail: { action: step.action_id, ...result.detail },
+ })
+ }
}
if (result.outcome === 'parked') {
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
index 05f6909..5dafc33 100644
--- a/server/test/eventRunner.test.js
+++ b/server/test/eventRunner.test.js
@@ -1028,6 +1028,112 @@ test('classify: the two success shapes that are not "finished"', () => {
assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded')
})
+// ── A module's own account of a successful step (Phase 15) ────────────────
+//
+// The third thing a success envelope may carry, and the only one core does not
+// interpret. It exists because a module knows things about its own verb that core
+// cannot compute and had no other channel for: `uo.item.grant` reaches the players
+// a run's ledger holds, and WHICH OF THEM MISSED OUT is reported nowhere else —
+// so before this, an operator saw a step marked `done` and never learned that four
+// of twelve got nothing. `module-uo` had been answering `detail` since Phase 12b
+// on the strength of one sentence in EVENTS.md §H, and core had never read it.
+
+test('classify: a success may carry a module detail, and it is never interpreted', () => {
+ assert.equal(classify({ ok: true }, 'a').detail, null, 'absent is null, not undefined')
+ assert.deepEqual(
+ classify({ ok: true, detail: { granted: 8, missed: 4 } }, 'a').detail,
+ { granted: 8, missed: 4 },
+ "the keys are the module own and core changes none of them",
+ )
+ // The other success shape. A cue's confirm finishes the step without a second
+ // dispatch, so this is the only moment its module could ever have said anything.
+ assert.deepEqual(
+ classify({ ok: true, await: 'human', detail: { cued: 'britain' } }, 'a').detail,
+ { cued: 'britain' },
+ )
+})
+
+test('classify: a bad detail is dropped, and never fails the step', () => {
+ // A step that did what it was asked must not be re-run because its module's
+ // commentary was malformed — that would be a world write repeated for a log
+ // line. Every one of these is `done` with a null detail.
+ const dropped = [
+ { ok: true, detail: 'a string' },
+ { ok: true, detail: 42 },
+ { ok: true, detail: ['an', 'array'] },
+ { ok: true, detail: { big: 'x'.repeat(5000) } },
+ ]
+ for (const envelope of dropped) {
+ const verdict = classify(envelope, 'a')
+ assert.equal(verdict.outcome, 'done', 'a bad detail must not change the outcome')
+ assert.equal(verdict.detail, null)
+ }
+
+ // A circular object throws inside JSON.stringify. Reaching the runner would
+ // make the log INSERT throw instead, inside the one write documented never to.
+ const circular = { ok: true, detail: {} }
+ circular.detail.self = circular.detail
+ assert.equal(classify(circular, 'a').outcome, 'done')
+ assert.equal(classify(circular, 'a').detail, null)
+})
+
+test("classify: the detail core carries is a copy, not the module object", () => {
+ const live = { granted: 8 }
+ const carried = classify({ ok: true, detail: live }, 'a').detail
+ live.granted = 999
+ assert.equal(carried.granted, 8, 'core must not hold a live reference into a module')
+})
+
+test('a module detail reaches the run log as its own line', async () => {
+ register([scriptedAction('test.grant')])
+ scripted['test.grant'] = {
+ calls: [],
+ answer: { ok: true, detail: { granted: 8, missed: 4, why: ['bank full'] } },
+ }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.grant')] }])
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'done')
+
+ const line = store.log.find((l) => l.runId === id && l.kind === 'step.detail')
+ assert.ok(line, 'the module said something and the run has no record of it')
+ assert.equal(line.detail.action, 'test.grant', "core's own key leads the line")
+ assert.equal(line.detail.granted, 8)
+ assert.equal(line.detail.missed, 4)
+ assert.deepEqual(line.detail.why, ['bank full'])
+ assert.equal(line.stepId, stepsOf(id)[0].id)
+})
+
+test('a step that says nothing writes no detail line', async () => {
+ // Its own line rather than a field on `resource.recorded`, so a run whose steps
+ // are all quiet must not gain a row per step saying so.
+ register([scriptedAction('test.quiet')])
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.quiet')] }])
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'done')
+ assert.equal(kinds(id).filter((k) => k === 'step.detail').length, 0)
+})
+
+test('a failed step reports no detail, however much it says', async () => {
+ // `detail` rides the SUCCESS shapes only. A failure's channel is `error`, and
+ // an action that answered both would otherwise get two bites at the log for a
+ // step that did not happen.
+ register([scriptedAction('test.refuse')])
+ scripted['test.refuse'] = {
+ calls: [],
+ answer: { ok: false, retry: false, error: 'no', detail: { tried: 3 } },
+ }
+
+ const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.refuse', {}, 'skip')] }])
+ await runner.tick(T0)
+
+ assert.equal(stepsOf(id)[0].status, 'failed')
+ assert.equal(kinds(id).filter((k) => k === 'step.detail').length, 0)
+})
+
test('an action that throws is a transient failure, not a crashed tick', async () => {
register([
scriptedAction('test.thrower', {
--
2.49.1
From 6dd4e5e3eb17a24d245a3aff12b332cd985b54a4 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 9 Sep 2026 08:28:17 -0500
Subject: [PATCH 17/18] fix(events): the public calendar, a stranded revert,
and three dropped facts (Phase 16a)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three defects the acceptance walk found in shipped code.
**The public calendar showed neither what is live nor what is recent.** §I says
`GET /public/events` is "the calendar: upcoming, **live** and **recent**". Built,
it was upcoming only: `listInWindow` filtered on `scheduled_for >= from` alone and
the shipped page asks for no window at all, so it took the default of now → +31d.
A run that began five minutes ago and has three hours to go was absent; so was one
that ended an hour ago. The site contradicted itself — `live: true` on
`/site/events/` while `/site/events` served `entries: []`.
A run is an interval, not an instant. `listInWindow` now matches a run whose
occupied interval OVERLAPS the window, which fixes the admin calendar's identical
hole (a run that started last Sunday and is still going was missing from "this
week"), and the public default reaches `DEFAULT_RECENT_DAYS` back so "recent" has
somewhere to live. Forecasts are still computed from `now`, never from the tail:
a projection into the past would advertise an occurrence that did not happen.
**A resource left `reverting` by a crash was never reclaimed.** `claimRevert`'s
comment said `reverting` is not claimable "exactly as a step with a live claim is"
— but a step's claim carries `claim_expires_at` and is reclaimed when the lease
lapses, and a resource in `reverting` had no expiry and nothing released it. A
process killed mid-teardown stranded the row for good: the sweep skipped it every
15s for ever, `cleanup_status` never left `pending`, and `POST …/cleanup` — the
recourse §I names — answered 200 and did nothing, because it claims through the
same function. On the rig it stranded a lease, which then BLOCKED the next run of
the same event from taking that value until the shard's own deadline lapsed.
The stale test is `updated_at`, which for a `reverting` row is exactly when the
claim was taken, so no column is added. `updated_at` is re-stamped explicitly and
that is load-bearing rather than tidy: this connector sends `CLIENT_FOUND_ROWS`,
so without the write a second claimer would still match the row. `revert_attempts`
is untouched — a stale claim is a process that died, not an attempt that failed.
**Three facts every event announcement computed and none could use.**
`announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven
`event.*` payloads, but four triggers declared none of them and a fifth declared
one, so `validatePayload` dropped them, they were absent from the variable list an
author picks from, and every emit logged `emit carried undeclared variables` at
DEBUG. They are now one shared `EVENT_AMBIENT` declaration spread into all seven,
with the per-trigger copies removed so the seven cannot drift.
Verified against a real ServUO + sidecar + website rig: the public page now shows
a live run as "Happening now" beside recent finished ones (it showed nothing at
all before), and a lease stranded by a real mid-teardown crash was reclaimed
within one sweep, taking `cleanup_status` from `pending` to `complete`.
The three `claimRevert` tests live in `eventRunnerSql.test.js` against a real
MariaDB, because every part of the answer is the server's — `NOW() - INTERVAL`,
`ON UPDATE`, and above all what `affectedRows` counts.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
---
server/engagement-triggers.json | 132 +++++++++++++++---
server/src/config/coreTriggers.js | 44 ++++--
server/src/model/events/eventPublic.model.js | 45 +++++-
.../src/model/events/eventRunResources.db.js | 48 ++++++-
server/src/model/events/eventRuns.db.js | 27 +++-
server/test/eventPublic.test.js | 92 +++++++++++-
server/test/eventRunnerSql.test.js | 71 ++++++++++
7 files changed, 407 insertions(+), 52 deletions(-)
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index a72784a..1d87575 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -27,6 +27,27 @@
"example": "The Yew Invasion",
"description": "The event title."
},
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event’s one-line summary, when it has one."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
+ },
{
"name": "phase",
"type": "string",
@@ -89,6 +110,27 @@
"example": "The Yew Invasion",
"description": "The event title."
},
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event’s one-line summary, when it has one."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
+ },
{
"name": "reason",
"type": "string",
@@ -135,7 +177,21 @@
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
- "description": "The event summary, as authored."
+ "description": "The event’s one-line summary, when it has one."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "participantCount",
@@ -185,6 +241,27 @@
"example": "The Yew Invasion",
"description": "The event title."
},
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event’s one-line summary, when it has one."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
+ },
{
"name": "eventUrl",
"type": "url",
@@ -219,6 +296,27 @@
"example": "The Yew Invasion",
"description": "The event title."
},
+ {
+ "name": "summary",
+ "type": "string",
+ "required": false,
+ "example": "Orcish warbands are massing north of Yew.",
+ "description": "The event’s one-line summary, when it has one."
+ },
+ {
+ "name": "seriesName",
+ "type": "string",
+ "required": false,
+ "example": "The Yew Campaign",
+ "description": "The arc this event belongs to, when it belongs to one."
+ },
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
+ },
{
"name": "phase",
"type": "string",
@@ -272,7 +370,7 @@
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
- "description": "The event summary, as authored."
+ "description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
@@ -281,6 +379,13 @@
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
+ },
{
"name": "startsAt",
"type": "datetime",
@@ -288,13 +393,6 @@
"example": "2026-09-12T20:00:00.000Z",
"description": "When the occurrence is due to start, UTC."
},
- {
- "name": "timezone",
- "type": "string",
- "required": false,
- "example": "America/New_York",
- "description": "The shard-local zone the schedule was authored in."
- },
{
"name": "startsAtLabel",
"type": "string",
@@ -341,7 +439,7 @@
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
- "description": "The event summary, as authored."
+ "description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
@@ -350,6 +448,13 @@
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
+ {
+ "name": "timezone",
+ "type": "string",
+ "required": false,
+ "example": "America/New_York",
+ "description": "The zone the run was computed in — what a time in the body should be read as."
+ },
{
"name": "startsAt",
"type": "datetime",
@@ -357,13 +462,6 @@
"example": "2026-09-12T20:00:00.000Z",
"description": "When it actually started, UTC."
},
- {
- "name": "timezone",
- "type": "string",
- "required": false,
- "example": "America/New_York",
- "description": "The shard-local zone the schedule was authored in."
- },
{
"name": "startsAtLabel",
"type": "string",
diff --git a/server/src/config/coreTriggers.js b/server/src/config/coreTriggers.js
index 414ccb6..711c058 100644
--- a/server/src/config/coreTriggers.js
+++ b/server/src/config/coreTriggers.js
@@ -29,6 +29,29 @@
// test-send without a live game event, which is the reason template systems go
// untested.
+/**
+ * The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
+ * once because they are spread into all seven.
+ *
+ * `baseFor()` has always computed them and nothing declared them, so
+ * `engagementEmit.validatePayload` dropped all three before a template could see
+ * one — they were absent from the variable list an author picks from, and every
+ * single event emit logged `emit carried undeclared variables`. Found by the
+ * Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
+ *
+ * All three are optional, and each for its own reason rather than by default: an
+ * event need not carry a summary, most events belong to no series, and a run
+ * whose definition has been deleted resolves no zone.
+ */
+const EVENT_AMBIENT = [
+ { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
+ description: 'The event’s one-line summary, when it has one.' },
+ { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
+ description: 'The arc this event belongs to, when it belongs to one.' },
+ { name: 'timezone', type: 'string', required: false, example: 'America/New_York',
+ description: 'The zone the run was computed in — what a time in the body should be read as.' },
+]
+
const TRIGGERS = [
{
id: 'news.post',
@@ -215,14 +238,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
- { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
- description: 'The event summary, as authored.' },
- { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
- description: 'The arc this event belongs to, when it belongs to one.' },
+ ...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When the occurrence is due to start, UTC.' },
- { name: 'timezone', type: 'string', required: false, example: 'America/New_York',
- description: 'The shard-local zone the schedule was authored in.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no
@@ -254,14 +272,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
- { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
- description: 'The event summary, as authored.' },
- { name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
- description: 'The arc this event belongs to, when it belongs to one.' },
+ ...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When it actually started, UTC.' },
- { name: 'timezone', type: 'string', required: false, example: 'America/New_York',
- description: 'The shard-local zone the schedule was authored in.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no
@@ -293,6 +306,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
+ ...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: true, example: 'assault',
description: 'The phase key just entered, as authored in the spec.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
@@ -323,6 +337,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
+ ...EVENT_AMBIENT,
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
@@ -345,8 +360,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
- { name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
- description: 'The event summary, as authored.' },
+ ...EVENT_AMBIENT,
// Counted from `event_run_participants` at emit. Zero on a run whose
// module reported nobody, which is every run until a module collects —
// a template that says "47 took part" needs a number that is never
@@ -377,6 +391,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
+ ...EVENT_AMBIENT,
// **The operator's reason, and not the run's `last_error`.** `cancel`
// takes a `{ reason }` a human typed for other humans; a diagnostic
// string is for the run console and would read as gibberish in a mail.
@@ -408,6 +423,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
+ ...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: false, example: 'assault',
description: 'The phase it failed in, when it had entered one.' },
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
diff --git a/server/src/model/events/eventPublic.model.js b/server/src/model/events/eventPublic.model.js
index 19a1c71..73a77fd 100644
--- a/server/src/model/events/eventPublic.model.js
+++ b/server/src/model/events/eventPublic.model.js
@@ -37,11 +37,19 @@ const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence')
-// The public calendar's window when a caller names neither end: now through a
-// month out. A visitor arriving at /site/events wants "what is on", and a client
-// that had to compute a window before it could ask anything would make every
-// deep link carry two ISO instants.
+// The public calendar's window when a caller names neither end: a few days BACK
+// through a month out. A visitor arriving at /site/events wants "what is on", and
+// a client that had to compute a window before it could ask anything would make
+// every deep link carry two ISO instants.
+//
+// **The backward tail is not padding — it is the "recent" in §I's "upcoming, live
+// and recent".** The default used to start at `now`, so an event that finished an
+// hour ago was already gone and a visitor had nowhere to find the results of the
+// thing they had just attended. The LIVE half is answered by `listInWindow`'s
+// overlap test rather than by this number, so the tail only has to be long enough
+// to be a "recently" a reader would recognise.
const DEFAULT_WINDOW_DAYS = 31
+const DEFAULT_RECENT_DAYS = 7
// How many past occurrences an event page carries. It shows what is next and
// what happened recently; the whole history of a three-year-old weekly event is
@@ -146,8 +154,15 @@ const publicProjectedEntry = (definition, occurrence) => ({
* surface that has no login in front of it.
*/
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
- const start = from ? new Date(from) : new Date(now)
- const end = to ? new Date(to) : new Date(start.getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
+ // The default `to` is measured from NOW, not from `start` — otherwise the
+ // backward tail would silently push the horizon a week further out and a caller
+ // naming only `from` would get a different span than one naming neither.
+ const start = from
+ ? new Date(from)
+ : new Date(new Date(now).getTime() - DEFAULT_RECENT_DAYS * recurrence.DAY_MS)
+ const end = to
+ ? new Date(to)
+ : new Date(new Date(now).getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] }
@@ -183,7 +198,22 @@ async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
if (!schedule || schedule.kind === 'manual') continue
let occurrences = []
try {
- occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', start, end)
+ // **Forecast from `now`, never from `start`.** The default window now
+ // reaches a week backwards so that "recent" has somewhere to live, and a
+ // projection into that tail would advertise an occurrence that did not
+ // happen — a run that WAS created is a real row and arrives above, and one
+ // that was not is a slot the runner has already passed. A forecast is about
+ // the future; the tail is about the past. Only the materialised half fills
+ // it.
+ const forecastFrom = start > now ? start : new Date(now)
+ if (forecastFrom < end) {
+ occurrences = recurrence.occurrencesBetween(
+ schedule,
+ definition.timezone || 'UTC',
+ forecastFrom,
+ end,
+ )
+ }
} catch {
// A version whose schedule the recurrence engine will not read is one the
// runner will not expand either. The calendar then shows that definition's
@@ -405,5 +435,6 @@ module.exports = {
publicStatus,
phaseLabel,
DEFAULT_WINDOW_DAYS,
+ DEFAULT_RECENT_DAYS,
PAST_RUNS,
}
diff --git a/server/src/model/events/eventRunResources.db.js b/server/src/model/events/eventRunResources.db.js
index 9f11af4..badd823 100644
--- a/server/src/model/events/eventRunResources.db.js
+++ b/server/src/model/events/eventRunResources.db.js
@@ -194,18 +194,54 @@ async function unresolvedCounts(runIds) {
}
/**
- * Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`.
+ * Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`,
+ * and `reverting` again once the claim on it has gone stale.
*
* The compare-and-set that keeps the cleanup leg and the manual cleanup route off
- * each other's rows. `reverting` is deliberately not claimable — a row another
- * pass is mid-revert on is left alone, exactly as a step with a live claim is.
+ * each other's rows. A row another pass is mid-revert on is left alone, exactly as
+ * a step with a live claim is.
+ *
+ * **"Exactly as a step" has to include the expiry, and it did not until the Phase
+ * 16 acceptance walk.** A step's claim carries `claim_expires_at`, so a step whose
+ * process died is reclaimed once the lease lapses — that reclaim is the whole
+ * reason §E's CAS survives §N4's single instance. A `reverting` row had no such
+ * bound and nothing released it, so a process killed mid-teardown stranded the row
+ * for good: the sweep skipped it every 15s forever, `cleanup_status` never left
+ * `pending`, and `POST …/cleanup` — the recourse §I names — answered 200 and did
+ * nothing, because it claims through this same function. Observed with a lease,
+ * which then blocked the NEXT run of the same event from taking the value.
+ *
+ * The stale test is `updated_at`, not a new column: the row is stamped exactly
+ * when it enters `reverting` and is not written again until the revert resolves,
+ * so for a `reverting` row `updated_at` IS "when this claim was taken". The bound
+ * is the run lease's, for the run lease's reason — it has to outlast a whole
+ * tick's work on one run, and every revert in a sweep is bounded by its action's
+ * own `budgetMs` long before this.
+ *
+ * `revert_attempts` is deliberately NOT incremented by reclaiming. A stale claim
+ * is a process that died, not an attempt that failed, and counting it would burn
+ * the retry budget on crashes — Engagement Phase 14's rule, one table over.
+ *
+ * **`updated_at` is re-stamped explicitly, and that is what keeps this a CAS.**
+ * This connector sends `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows MATCHED
+ * rather than changed. For the four fresh statuses that is harmless — the winner
+ * moves the row to `reverting` and the loser's `status IN (…)` no longer matches.
+ * A stale `reverting` row has no such natural change: without re-stamping, the
+ * row would still satisfy `status = 'reverting' AND updated_at < …` and a second
+ * claimer would match it too. Writing the column is what makes the second one
+ * miss.
*/
+const REVERT_CLAIM_TTL_MS = Number(process.env.EVENT_REVERT_CLAIM_TTL_MS) || 15 * 60 * 1000
+
async function claimRevert(id) {
const result = await query(
`UPDATE event_run_resources
- SET status = 'reverting'
- WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned', 'drifted')`,
- [id],
+ SET status = 'reverting', updated_at = NOW()
+ WHERE id = ?
+ AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
+ OR (status = 'reverting'
+ AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`,
+ [id, REVERT_CLAIM_TTL_MS * 1000],
)
return (result.affectedRows || 0) > 0
}
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index 0825f4f..e333dfb 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -108,14 +108,32 @@ const materialise = async (run) => {
}
/**
- * Every run whose instant falls inside a window — the calendar's real half.
+ * Every run whose OCCUPIED INTERVAL overlaps a window — the calendar's real half.
*
* Ascending, unlike the admin run list: a calendar is read forwards. The join
* reaches the series so a month can be filtered to one arc without a second
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
* a run records the zone it was COMPUTED in and a definition's zone can be
* edited afterwards.
+ *
+ * **A run OVERLAPS the window; it does not merely START in it.** This asked
+ * `scheduled_for >= from` alone until the Phase 16 acceptance walk, and a run is
+ * not an instant — it is an interval, and a multi-phase event's whole point is
+ * that the interval is long. A run that began before `from` and has not ended is
+ * happening DURING the window and belongs in it. With the instant test, the
+ * public calendar answered `entries: []` while that same event's own page said
+ * `live: true`, so the site disagreed with itself about whether something was on
+ * — and `EVENTS.md` §I promises this route serves "upcoming, **live** and
+ * recent". The admin calendar had the same hole for the same reason: a run that
+ * started last Sunday and is still going was missing from "this week".
+ *
+ * A finished run needs no clause: it is `recent` only if its instant is in the
+ * window, which is what the window's own `from` decides (see
+ * `eventPublic.model.calendar`, which backdates its default `from` so that
+ * "recent" has somewhere to live).
*/
+const LIVE_STATUSES = ['starting', 'running', 'paused', 'ending']
+
const listInWindow = async ({
from,
to,
@@ -125,8 +143,11 @@ const listInWindow = async ({
limit = 500,
publicOnly = false,
} = {}) => {
- const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
- const args = [from, to]
+ const where = [
+ `((r.scheduled_for >= ? AND r.scheduled_for < ?)
+ OR (r.scheduled_for < ? AND r.status IN (${LIVE_STATUSES.map(() => '?').join(',')})))`,
+ ]
+ const args = [from, to, to, ...LIVE_STATUSES]
if (status) {
where.push('r.status = ?')
args.push(status)
diff --git a/server/test/eventPublic.test.js b/server/test/eventPublic.test.js
index 17119c5..b5758e2 100644
--- a/server/test/eventPublic.test.js
+++ b/server/test/eventPublic.test.js
@@ -115,10 +115,15 @@ function installStubs() {
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
.map((d) => ({ ...d, version_spec: d.spec }))
+ // Mirrors the OVERLAP predicate the real statement uses: a run is in the window
+ // if its instant falls inside it, OR if it began before the window and is still
+ // live. A run is an interval, not an instant — see `eventRuns.db.listInWindow`.
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
store.runs.filter((r) => {
const at = new Date(r.scheduled_for)
- if (at < from || at >= to) return false
+ const startsInside = at >= from && at < to
+ const liveAcross = at < to && ['starting', 'running', 'paused', 'ending'].includes(r.status)
+ if (!startsInside && !liveAcross) return false
if (!publicOnly) return true
const d = store.definitions.find((x) => x.id === r.definition_id)
return !r.rehearsal && d && d.listed && d.state !== 'archived'
@@ -163,12 +168,89 @@ test('a calendar entry carries no operational field at all', async () => {
])
})
-test('the calendar defaults to a month from now when no window is given', async () => {
+test('the default window reaches back as well as forward', async () => {
+ // §I: this route is "upcoming, live and recent". The default used to start at
+ // `now`, which left no room for the third word — an event that finished an hour
+ // ago was already gone, so a visitor had nowhere to find the results of the
+ // thing they had just attended (Phase 16 walk).
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
- assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
- const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
- assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
+ const back = (NOW - new Date(result.window.from)) / 86_400_000
+ const forward = (new Date(result.window.to) - NOW) / 86_400_000
+ assert.equal(back, publicModel.DEFAULT_RECENT_DAYS)
+ assert.equal(forward, publicModel.DEFAULT_WINDOW_DAYS)
+})
+
+test('a run happening RIGHT NOW is on the calendar, whenever it started', async () => {
+ // The defect this pair was written for: the site said `live: true` on the
+ // event's own page and served `entries: []` from the calendar, because the
+ // window test read the START instant and a live run had already started. A run
+ // is an interval; the calendar asks which intervals overlap it.
+ store.runs = [
+ {
+ ...run({
+ status: 'running',
+ // Well before any default window would begin.
+ scheduled_for: new Date('2026-08-01T00:00:00Z'),
+ ended_at: null,
+ }),
+ definition_title: 'The Yew Invasion',
+ definition_slug: 'the-yew-invasion',
+ },
+ ]
+ const result = await publicModel.calendar({ now: NOW })
+ assert.equal(result.ok, true)
+ const entry = result.entries.find((e) => e.kind === 'run')
+ assert.ok(entry, 'a live run must appear however long ago it began')
+ assert.equal(entry.live, true)
+ assert.equal(entry.status, 'live')
+})
+
+test('a run that finished inside the recent tail is still on the calendar', async () => {
+ store.runs = [
+ {
+ ...run({
+ status: 'completed',
+ scheduled_for: new Date(NOW.getTime() - 2 * 86_400_000),
+ ended_at: new Date(NOW.getTime() - 2 * 86_400_000 + 3_600_000),
+ }),
+ definition_title: 'The Yew Invasion',
+ definition_slug: 'the-yew-invasion',
+ },
+ ]
+ const result = await publicModel.calendar({ now: NOW })
+ assert.equal(result.ok, true)
+ assert.equal(result.entries.filter((e) => e.kind === 'run').length, 1)
+})
+
+test('nothing is FORECAST into the recent tail', async () => {
+ // The tail is for what happened, and only the materialised half fills it. A
+ // projection into the past would advertise an occurrence that did not happen:
+ // one that WAS created is a real row and arrives as a run, and one that was not
+ // is a slot the runner has already gone past.
+ store.definitions = [
+ definition({
+ spec: {
+ ...SPEC,
+ schedule: {
+ kind: 'weekly',
+ days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
+ time: '20:00',
+ },
+ },
+ }),
+ ]
+ store.runs = []
+ const result = await publicModel.calendar({ now: NOW })
+ assert.equal(result.ok, true)
+ const projected = result.entries.filter((e) => e.kind !== 'run')
+ assert.ok(projected.length > 0, 'a daily schedule must still forecast forwards')
+ for (const entry of projected) {
+ assert.ok(
+ new Date(entry.scheduledFor) >= NOW,
+ `forecast ${entry.scheduledFor} is before now — the tail must hold no projections`,
+ )
+ }
})
test('a window wider than the cap is refused rather than served slowly', async () => {
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
index ce69605..9cb4cfa 100644
--- a/server/test/eventRunnerSql.test.js
+++ b/server/test/eventRunnerSql.test.js
@@ -1448,6 +1448,77 @@ test('many released rows on one target coexist, which is the whole encoding', as
assert.equal(await dup(() => insertResource(a.runId, { kind: 'override', ref: 'demo.rate' })), null)
})
+// ── claimRevert's stale-claim reclaim (Phase 16) ───────────────────────────
+//
+// The statement's own comment explains WHY a `reverting` row must be reclaimable;
+// this proves it against a real server, because every part of the answer is the
+// server's: `NOW() - INTERVAL … MICROSECOND`, whether `ON UPDATE` re-stamps, and
+// above all what `affectedRows` counts. This connector sends `CLIENT_FOUND_ROWS`,
+// so it counts rows MATCHED — a stub counting CHANGED rows would call the reclaim
+// a failure, and one counting matched rows would miss that the second claimer
+// needs the re-stamp in order to lose. Only MariaDB settles it.
+
+const CLAIM_REVERT = `
+ UPDATE event_run_resources
+ SET status = 'reverting', updated_at = NOW()
+ WHERE id = ?
+ AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
+ OR (status = 'reverting'
+ AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`
+
+const claimRevert = async (id, ttlMs) =>
+ Number((await pool.query(CLAIM_REVERT, [id, ttlMs * 1000]))?.affectedRows || 0) > 0
+
+const ageResource = (id, seconds) =>
+ pool.query('UPDATE event_run_resources SET updated_at = NOW() - INTERVAL ? SECOND WHERE id = ?', [
+ seconds,
+ id,
+ ])
+
+test('a reverting row whose claim has gone stale is claimable again', async (t) => {
+ if (needDb(t)) return
+ // The Phase 16 walk's finding: a process killed mid-teardown leaves the row in
+ // `reverting` and nothing releases it. The sweep ran every 15s for ever finding
+ // nothing it could claim, `cleanup_status` never left `pending`, and the manual
+ // retry answered 200 while doing nothing — it claims through this statement too.
+ const run = await seedRun()
+ const id = await insertResource(run.runId, { status: 'reverting' })
+
+ // Fresh: somebody else really is mid-revert on it. Left alone.
+ assert.equal(await claimRevert(id, 900_000), false)
+
+ // Stale: the holder is not coming back.
+ await ageResource(id, 1800)
+ assert.equal(await claimRevert(id, 900_000), true)
+})
+
+test('reclaiming re-stamps, so the second claimer of one stale row loses', async (t) => {
+ if (needDb(t)) return
+ // Under CLIENT_FOUND_ROWS the four fresh statuses need no re-stamp — the winner
+ // moves the row out of `status IN (…)` and the loser stops matching. A stale
+ // `reverting` row has no such natural change, so without writing `updated_at`
+ // BOTH claimers would match it and two passes would revert the same resource.
+ const run = await seedRun()
+ const id = await insertResource(run.runId, { status: 'reverting' })
+ await ageResource(id, 1800)
+
+ assert.equal(await claimRevert(id, 900_000), true)
+ assert.equal(await claimRevert(id, 900_000), false, 'the re-stamp must make the second miss')
+})
+
+test('reclaiming a stale revert does not spend a retry attempt', async (t) => {
+ if (needDb(t)) return
+ // A stale claim is a process that died, not an attempt that failed. Counting it
+ // would burn MAX_REVERT_ATTEMPTS on crashes — Engagement Phase 14's rule, one
+ // table over.
+ const run = await seedRun()
+ const id = await insertResource(run.runId, { status: 'reverting' })
+ await ageResource(id, 1800)
+ await claimRevert(id, 900_000)
+ const [row] = await pool.query('SELECT revert_attempts FROM event_run_resources WHERE id = ?', [id])
+ assert.equal(Number(row.revert_attempts), 0)
+})
+
test('an UPDATE that releases a row frees the target at once', async (t) => {
if (needDb(t)) return
// The generated column is STORED, so this is really asking whether MariaDB
--
2.49.1
From 7d7840eb6b2fb3a7f0b5d01752e96357bf9137c3 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 9 Sep 2026 19:20:31 -0500
Subject: [PATCH 18/18] fix(events): midnight in an announcement is 12:00 am on
the Node we ship
`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same
request as a 12-hour clock, and it does not survive a Node upgrade: for a
locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11,
whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later
resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it
is V8's ECMA-402 behaviour rather than locale data.
The image ships node:20-alpine and CI runs Node 20, while a dev machine is
newer -- which is how this rendered correctly in front of everyone who wrote
it and wrongly for every real recipient. An event mail announcing a midnight
start said "0:00 am" while the schedule editor beside it said "12:00 AM":
one instant, two spellings, which is the exact contradiction the option was
added to prevent.
`hourCycle: 'h12'` is the request that means what was meant. `recurrence.js`
already states the mirror-image rule for `h23`, and every other formatter in
this repo and in module-uo uses `hourCycle`; there is no `hour12` left here.
This is the one test that has been red on every events PR since #192, and
the only one -- each of those runs reported `# fail 1`. Verified by running
the suite under node:20-alpine, where the test fails without this change and
2152 tests pass with it; on Node 22+ it passes either way, so the test's
comment now says that a green run on a dev machine is not evidence.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
---
server/src/events/announce.js | 13 ++++++++++++-
server/test/eventAnnounce.test.js | 8 +++++++-
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/server/src/events/announce.js b/server/src/events/announce.js
index e8d0f76..db52ade 100644
--- a/server/src/events/announce.js
+++ b/server/src/events/announce.js
@@ -67,7 +67,18 @@ function startsAtLabel(at, zone) {
// Explicit rather than left to the locale, because `en-GB` would otherwise
// render midnight as "00:00" while the schedule editor beside it writes
// "12:00 AM" — one event, two spellings of the same instant.
- hour12: true,
+ //
+ // `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
+ // and does not survive a Node upgrade. For a locale whose default cycle is
+ // h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
+ // hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
+ // resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
+ // is V8's ECMA-402 behaviour and not locale data, and the image ships
+ // node:20-alpine while a dev machine is newer — which is how this rendered
+ // correctly in front of everyone who wrote it and wrongly for every real
+ // recipient. `recurrence.js` states the mirror-image rule for `h23`; there
+ // is no `hour12` left in this repo and it should stay that way.
+ hourCycle: 'h12',
}).format(when)
return `${text} (${zone || 'UTC'})`
} catch {
diff --git a/server/test/eventAnnounce.test.js b/server/test/eventAnnounce.test.js
index bb72fc3..4d09e4c 100644
--- a/server/test/eventAnnounce.test.js
+++ b/server/test/eventAnnounce.test.js
@@ -260,9 +260,15 @@ test('the start time is written out in the SHARD\'s zone, not the server\'s', ()
})
test('midnight reads as 12:00 am and never as 00:00', () => {
- // `hour12` is set explicitly. Left to the en-GB locale this would render
+ // The hour cycle is set explicitly. Left to the en-GB locale this would render
// "00:00" while the schedule editor beside it writes "12:00 AM" — one event,
// two spellings of the same instant.
+ //
+ // This assertion only has teeth on the Node the image ships (20), where
+ // `hour12: true` resolves to h11 and midnight reads "0:00 am". On Node 22+ it
+ // passes either way — so a green run on a dev machine is not evidence, and CI
+ // is what actually holds this line. See the note beside `hourCycle` in
+ // events/announce.js.
assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/)
})
--
2.49.1