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 `