feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s

`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 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
2026-09-04 13:06:46 -05:00
parent d4516739b4
commit 7d3d6d5abd
36 changed files with 2960 additions and 39 deletions

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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/<slug>`, 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 runs 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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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`. */

View File

@@ -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,
}

View File

@@ -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 || [],
}
}
/**

View File

@@ -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 }

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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) }

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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,
})),
}
}

View File

@@ -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 `<select>`.
async function listPublishedForOptions(limit = 200) {
const n = Math.min(Math.max(Number(limit) || 200, 1), 500)
return query(
'SELECT id, category, title FROM posts WHERE published = 1 ' +
`ORDER BY COALESCE(published_at, created_at) DESC, id DESC LIMIT ${n}`,
)
}
// All posts for a category (admin), newest first.
async function listAll(category) {
if (category) {
@@ -74,6 +87,7 @@ async function countByCategory() {
module.exports = {
listPublished,
listPublishedForOptions,
listAll,
findById,
findPublished,

View File

@@ -92,6 +92,10 @@ const shapeRun = (r) => ({
rehearsal: r.rehearsal,
startedAt: r.started_at,
endedAt: r.ended_at,
// When the results table was ranked and published (Phase 10). On the LIST as
// well as the console, because "which of last month's events still have no
// published results" is a question about a list.
resultsPublishedAt: r.results_published_at,
lastError: r.last_error,
createdAt: r.created_at,
// How many steps are parked on a human. Derived, not a column, and surfaced on
@@ -320,6 +324,11 @@ exports.getRun = async (req, res) => {
// question with two halves, and a list of only the failures answers neither.
resources: found.resources,
unresolvedResources: found.unresolvedResources,
// Who took part, best first (Phase 10). `rank` is null on every row until
// `core.results.publish` has ranked them, which is what lets the console
// show a collected-but-unpublished run as exactly that rather than
// inventing an ordering nobody settled.
participants: found.participants,
})
}

View File

@@ -209,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. `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.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 builders 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. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #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[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', 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" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.getRun,
)

View File

@@ -21,6 +21,7 @@
// silently loses a variable is a template that silently renders `undefined`.
const registries = require('../modules/registries')
const ceilings = require('../modules/ceilings')
const engine = require('../engagement/engine')
const eventGates = require('../events/gates')
const scopedPrefs = require('../engagement/scopedPrefs')
@@ -166,7 +167,7 @@ function emit(owner, triggerId, envelope = {}) {
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
}
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds } = envelope || {}
const { subject, data, ownerUserId, dedupeKey, occurredAt, scopeKey, recipientUserIds, ceiling } = envelope || {}
const payload = validatePayload(declaration, data)
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
@@ -226,6 +227,33 @@ function emit(owner, triggerId, envelope = {}) {
resolvedRecipients = [...new Set(recipientUserIds)]
}
// **A ceiling this ONE firing may not exceed** (MODULE_API 1.11.0, EVENTS.md
// §I, org lead 2026-09-04). A trigger's declared ceiling is a property of the
// KIND of event; this is a property of the occasion, and the two are different
// questions. The case that forced it is the rehearsal: §I promises an event
// can be "run for real with announcements ceilinged to `staff`", and a
// rehearsal fires exactly the same trigger as the real thing — so without a
// per-firing bound, rehearsing a published event mails every subscriber it.
//
// **It only ever NARROWS.** The send-time G24 gate takes
// `meet(declared, emitted)`, so an emitter can tighten a ceiling and can never
// loosen one, and an emitter that names something incomparable with the
// declaration — `owner` against a declared `staff` — meets to null and the gate
// refuses every rule rather than guessing which branch was meant. That is the
// lattice's existing posture (`segments.js` §5.1a rule 3), reused rather than
// re-argued.
//
// Not stored on the outbox row, deliberately: by the time a row exists the gate
// has already run, and a second copy of the bound would be a second thing that
// can disagree with the declaration it was checked against.
let resolvedCeiling = null
if (ceiling !== undefined && ceiling !== null) {
if (!ceilings.isCeiling(ceiling)) {
return fail(`ceiling must be one of ${ceilings.CEILINGS.join(', ')}`)
}
resolvedCeiling = ceiling
}
if (dedupeKey !== undefined && dedupeKey !== null) {
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
@@ -247,6 +275,7 @@ function emit(owner, triggerId, envelope = {}) {
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
scopeKey: resolvedScope,
recipientUserIds: resolvedRecipients,
ceiling: resolvedCeiling,
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
occurredAt: at.toISOString(),
data: payload.data,

View File

@@ -79,6 +79,8 @@ const spec = require('../events/spec')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
const ledger = require('../events/ledger')
const participants = require('../events/participants')
const announce = require('../events/announce')
const cleanup = require('../events/cleanup')
const authorize = require('../events/authorize')
const log = require('./logger')('event-runner')
@@ -197,6 +199,7 @@ async function applyFailure(run, step, error, { status = 'failed', kind = 'step.
phase: step.phase,
detail: { to: 'failed', because: step.action_id, cancelledSteps: cancelled },
})
await announce.runFailed({ ...run, current_phase: step.phase }, error)
return 'stop'
}
@@ -333,6 +336,31 @@ async function drainStep(run, step, now, carry = {}) {
},
})
}
// **Who it happened to, recorded beside what it did** (Phase 10). Same two
// success shapes, same posture: reported on the envelope, written here,
// never allowed to fail the step. Its own log line rather than a field on
// `resource.recorded` because the two answer different questions and a run
// very often has one without the other.
const attended = await participants.recordAnswer({
run,
step,
action,
participants: result.participants,
})
if (attended.recorded > 0 || attended.rejected.length > 0) {
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'participants.recorded',
phase: step.phase,
detail: {
action: step.action_id,
recorded: attended.recorded,
...(attended.rejected.length ? { rejected: attended.rejected } : {}),
},
})
}
}
if (result.outcome === 'parked') {
@@ -502,6 +530,7 @@ async function advanceRun(run, now) {
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' } })
await announce.runFailed(run, 'the pinned version has no phases')
return 'failed'
}
@@ -518,6 +547,12 @@ async function advanceRun(run, now) {
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' } })
// **After the guarded transition, never before it** (Phase 10). The
// transition is the compare-and-set that decides which tick owns this run;
// announcing on the losing side of it would mail the same "starting now" for
// every process that tried. The same rule holds at each of the six emits
// below, and it is why none of them sits beside a `materialisePhase` call.
await announce.runStarted({ ...run, current_phase: first.key }, now)
}
if (run.status === 'ending') {
@@ -528,6 +563,11 @@ async function advanceRun(run, now) {
// 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' } })
// The recovery path: a run that reached the wind-down and lost its process.
// `run.ending` is deliberately NOT re-emitted here — the tick that put this
// run into `ending` already announced it, and the whole reason this branch
// exists is that the process died afterwards.
await announce.runCompleted(run, now)
return 'completed'
}
@@ -546,8 +586,10 @@ async function advanceRun(run, now) {
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` })
const error = `phase "${phaseKey}" is not in the pinned version`
await runsDb.transition(run.id, ['running'], 'failed', { error })
await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'failed', because: `unknown phase "${phaseKey}"` } })
await announce.runFailed({ ...run, current_phase: phaseKey }, error)
return 'failed'
}
@@ -605,8 +647,15 @@ async function advanceRun(run, now) {
// 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 announce.runEnding({ ...run, current_phase: phaseKey })
await runsDb.transition(run.id, 'ending', 'completed')
await logDb.write({ runId: run.id, kind: 'run.status', detail: { from: 'ending', to: 'completed' } })
// **`ending` and `completed` in the same tick, so both fire moments apart**,
// and that is honest rather than tidy: §E's `ending` is what a claim sets,
// and a run passes straight through it. A deployment that wants only one
// of the two writes one rule; the pair exists because a run whose teardown
// is slow does linger there, and an operator watching one wants to know.
await announce.runCompleted({ ...run, current_phase: phaseKey }, now)
return 'completed'
}
@@ -624,6 +673,15 @@ async function advanceRun(run, now) {
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 } })
// The FIRST phase does not fire this — `event.run.started` already said the
// event began, and a deployment with a rule on each would announce the
// opening twice, seconds apart, saying the same thing.
await announce.phaseChanged({ ...run, current_phase: next.key }, {
phase: next.key,
label: next.label,
index: phaseIndex + 1,
count: phases.length,
})
}
return 'bounded' // more to do; the next tick picks it up
@@ -759,6 +817,13 @@ async function expandSchedules(now) {
}
if (!result.created) continue
created += 1
// **On materialisation, not on publish.** An occurrence is the thing a
// player can be told about — it has a date — and a definition does not.
// It fires up to `HORIZON_DAYS` ahead of the event, which is what makes
// it the "save the date" trigger rather than the "starting now" one; a
// rule wanting a reminder closer to the hour is a `delay_seconds` on
// this or a rule on `event.run.started`.
await announce.runScheduled(result.run)
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