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

@@ -0,0 +1,311 @@
// ── The integrations (EVENTS_PLAN.md Phase 10) ─────────────────────────────
//
// §J's three reuse claims, each turned into something that fails when the reuse
// stops being real:
//
// • **`core.announce.post` reuses the announce legs** rather than growing a
// second delivery pipeline — so the assertions are about what it puts in
// `announce_jobs`, not about what reaches Discord.
// • **an event's job must not stand on the news pipeline's toes.** A post may
// now have two jobs, and everything that reads "the post's job" — the admin
// panel, its retry button, `announced_at` — must still mean the news one.
// This is the half that would be silent: nothing errors, the panel just
// starts showing a different row.
// • **`core.results.publish` is idempotent**, because it is an ordinary step
// an author may place twice and the runner may retry.
//
// And the two seeded rules, checked against the declarations they name. A seeded
// rule pointing at a template that does not exist, or at an audience its own
// trigger's ceiling forbids, is a rule that fails on the first firing after an
// operator switches it on — which is the worst possible moment to find out.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const ceilings = require('../src/modules/ceilings')
const coreRules = require('../src/engagement/coreRules')
const templateSeeds = require('../src/engagement/templateSeeds')
const { TRIGGERS } = require('../src/config/coreTriggers')
const postsDb = require('../src/model/posts/posts.db')
const postsModel = require('../src/model/posts/posts.model')
const announceDb = require('../src/model/announceJobs/announceJobs.db')
const announceModel = require('../src/model/announceJobs/announceJobs.model')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const runsDb = require('../src/model/events/eventRuns.db')
const db = require('../src/utils/db')
after(() => db.close())
// ── core.announce.post ─────────────────────────────────────────────────────
const POSTS = {
1: { id: 1, category: 'news', title: 'The Yew Invasion', published: 1, announce_job_id: 9 },
2: { id: 2, category: 'news', title: 'A draft', published: 0, announce_job_id: null },
3: { id: 3, category: 'newsletter', title: 'Never announced', published: 1, announce_job_id: null },
}
let enqueued
let linked
const originals = {
getById: postsModel.getById,
link: postsModel.linkAnnounceJob,
create: announceDb.create,
legIds: registries.announceLegIds,
}
beforeEach(() => {
registries._reset()
registries.registerCore()
enqueued = []
linked = []
postsModel.getById = async (id) => POSTS[id] || null
postsModel.linkAnnounceJob = async (id, jobId) => {
linked.push({ id, jobId })
}
announceDb.create = async (postId, legs, opts = {}) => {
enqueued.push({ postId, legs, runId: opts.runId ?? null })
return 100 + enqueued.length
}
})
after(() => {
postsModel.getById = originals.getById
postsModel.linkAnnounceJob = originals.link
announceDb.create = originals.create
registries.announceLegIds = originals.legIds
})
const announcePost = () => registries.eventAction('core.announce.post')
test('a published post is queued on every registered leg, tagged with the run', async () => {
const answer = await announcePost().perform({ runId: 3692, params: { postId: 1 }, verify: false })
assert.deepEqual(answer, { ok: true })
assert.equal(enqueued.length, 1)
assert.equal(enqueued[0].postId, 1)
assert.equal(enqueued[0].runId, 3692)
// The legs come from the registry, so a module's leg is included without this
// action naming one — which is the whole of "reuse the legs".
assert.deepEqual(enqueued[0].legs, registries.announceLegIds())
})
test('an unpublished post is refused, and refused terminally', async () => {
// 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. Publishing is the CMS's decision and this action is not it.
const answer = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: false })
assert.equal(answer.ok, false)
assert.equal(answer.retry, false)
assert.match(answer.error, /not published/)
assert.equal(enqueued.length, 0)
})
test('a post id that names nothing is refused terminally — it will not appear in sixty seconds', async () => {
for (const bad of [999, 0, -1, 'twelve', null]) {
const answer = await announcePost().perform({ runId: 1, params: { postId: bad }, verify: false })
assert.equal(answer.ok, false, `${bad}`)
assert.equal(answer.retry, false, `${bad}`)
}
assert.equal(enqueued.length, 0)
})
test('a dry run checks the post and queues nothing', async () => {
const answer = await announcePost().perform({ runId: 1, params: { postId: 1 }, verify: true })
assert.deepEqual(answer, { ok: true })
assert.equal(enqueued.length, 0)
// …and still refuses what the real run would refuse, which is the point of a
// dry run: the cost of finding out is a page, not a half-changed world.
const refused = await announcePost().perform({ runId: 1, params: { postId: 2 }, verify: true })
assert.equal(refused.ok, false)
})
test('the post\'s back-pointer is left alone when it already has one', async () => {
// `announce_job_id` is what the post admin panel reads and what
// `shouldEnqueue` guards on. Moving it to an event's job would make a
// re-published post announce itself again.
await announceModel.enqueueForRun(1, 3692)
assert.deepEqual(linked, [])
})
test('a post that has never been announced gains the pointer, because this IS its announcement', async () => {
await announceModel.enqueueForRun(3, 3692)
assert.equal(linked.length, 1)
assert.equal(linked[0].id, 3)
})
// ── the news pipeline is untouched ─────────────────────────────────────────
test('"the post\'s job" still means the news one, however many an event has added', async () => {
// The half that would be silent: nothing errors, the admin panel just starts
// rendering an event's job and its retry button retries that instead.
//
// Asserted against the query's own text rather than by stubbing the pool.
// `announceJobs.db.js` destructures `query` at require time, so a stub on the
// db module here would replace something nothing reads — and the test would
// then pass whatever the SQL said, which is the one thing it exists to check.
const source = require('node:fs').readFileSync(
require.resolve('../src/model/announceJobs/announceJobs.db'),
'utf8',
)
const fn = source.slice(source.indexOf('async function findByPostId'))
assert.match(fn.slice(0, fn.indexOf('\n}')), /run_id IS NULL/)
})
test('a run\'s job does not restamp the post\'s announced_at', async () => {
// `announced_at` means "when this post was announced". An event linking a
// three-week-old article would otherwise rewrite that to today, and the admin
// panel would report a publication date the post does not have.
const stamped = []
const saved = { findById: announceDb.findById, setStatus: announceDb.setStatus, markAnnounced: postsModel.markAnnounced }
announceDb.findById = async (id) => ({
id,
post_id: 1,
run_id: id === 1 ? null : 3692,
status: 'pending',
legs: [{ leg: 'discord', status: 'done' }],
})
announceDb.setStatus = async () => {}
postsModel.markAnnounced = async (postId) => {
stamped.push(postId)
}
try {
await announceModel.refreshStatus(2) // a run's job
assert.deepEqual(stamped, [])
await announceModel.refreshStatus(1) // the news pipeline's own
assert.deepEqual(stamped, [1])
} finally {
Object.assign(announceDb, { findById: saved.findById, setStatus: saved.setStatus })
postsModel.markAnnounced = saved.markAnnounced
}
})
// ── core.results.publish ───────────────────────────────────────────────────
test('publishing ranks the run\'s participants and stamps it, and does neither on a dry run', async () => {
const calls = []
const saved = { rank: participantsDb.rankRun, mark: runsDb.markResultsPublished }
participantsDb.rankRun = async (id) => {
calls.push(['rank', id])
return 3
}
runsDb.markResultsPublished = async (id) => {
calls.push(['stamp', id])
return true
}
try {
const action = registries.eventAction('core.results.publish')
assert.deepEqual(await action.perform({ runId: 3692, verify: true }), { ok: true })
assert.deepEqual(calls, [])
assert.deepEqual(await action.perform({ runId: 3692, verify: false }), { ok: true })
assert.deepEqual(calls, [['rank', 3692], ['stamp', 3692]])
// Idempotent by construction: the ranking is a total order over
// `(score, joined_at, id)`, so a second publication writes the same numbers.
// That is what makes it safe as an ordinary retried step.
await action.perform({ runId: 3692, verify: false })
assert.equal(calls.length, 4)
} finally {
participantsDb.rankRun = saved.rank
runsDb.markResultsPublished = saved.mark
}
})
test('publishing takes no params — a run may not publish somebody else\'s results', async () => {
assert.deepEqual(registries.eventAction('core.results.publish').params, [])
})
test('publishing is `inspect`, so it is default-ON and an author can place it unaided', () => {
// Nothing in the game world changes and nobody is messaged: a table core
// already holds becomes readable.
const action = registries.eventAction('core.results.publish')
assert.equal(action.risk, 'inspect')
assert.equal(action.reversible, 'none')
})
// ── the option source ──────────────────────────────────────────────────────
test('the posts option source answers value/label/group from published posts only', async () => {
const saved = postsDb.listPublishedForOptions
postsDb.listPublishedForOptions = async () => [
{ id: 1, category: 'news', title: 'The Yew Invasion' },
{ id: 3, category: 'newsletter', title: 'Never announced' },
]
try {
// Through the resolver the catalog route uses, so the normalisation it
// applies — every value stringified for the `<select>` — is exercised too
// rather than only the raw `resolve()`.
const answer = await registries.resolveOptionSource('core.options.posts')
assert.equal(answer.ok, true)
assert.deepEqual(answer.options, [
{ value: '1', label: 'The Yew Invasion', group: 'news' },
{ value: '3', label: 'Never announced', group: 'newsletter' },
])
} finally {
postsDb.listPublishedForOptions = saved
}
})
// ── the seeded rules ───────────────────────────────────────────────────────
const declared = (id) => TRIGGERS.find((t) => t.id === id)
const seededKeys = new Set(templateSeeds.SEEDS.map((s) => s.key))
test('two rules are seeded, both for triggers core declares', () => {
assert.equal(coreRules.EVENT_RULES.length, 2)
for (const rule of coreRules.EVENT_RULES) {
assert.ok(declared(rule.trigger_id), `${rule.trigger_id} is not declared`)
}
})
test('every seeded rule names templates that exist', () => {
// A rule pointing at a template that does not exist fails on the first firing
// after an operator switches it on, which is the worst moment to find out.
for (const rule of coreRules.EVENT_RULES) {
for (const [channel, key] of Object.entries(rule.template_keys)) {
assert.ok(seededKeys.has(key), `${rule.trigger_id}.${channel} names "${key}", which is not seeded`)
}
}
})
test('no seeded rule asks for an audience its trigger\'s ceiling forbids', () => {
for (const rule of coreRules.EVENT_RULES) {
const trigger = declared(rule.trigger_id)
assert.ok(
ceilings.permits(trigger.ceiling, rule.audience),
`${rule.trigger_id} is ceilinged ${trigger.ceiling} and the seeded rule asks for ${rule.audience}`,
)
}
})
test('the failure rule stays at admin and carries no push', () => {
const failed = coreRules.EVENT_RULES.find((r) => r.trigger_id === 'event.run.failed')
assert.equal(failed.audience, 'admin')
// 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.
assert.ok(!failed.channels.includes('push'))
// No cooldown, and it is the one rule here that must not have one: the subject
// is the run, so a cooldown would only ever suppress a second failure of the
// very run an administrator most needs the second line about.
assert.equal(failed.cooldown_seconds, 0)
})
test('every seeded rule carries an hourly ceiling — a module may choose the number, not decline one', () => {
for (const rule of coreRules.EVENT_RULES) {
assert.ok(Number.isInteger(rule.max_sends_per_hour) && rule.max_sends_per_hour > 0, rule.trigger_id)
}
})
test('the event rules have their OWN one-shot key, so an upgraded deployment still gets them', () => {
// The Team key is stamped on every deployment that has booted since Phase 6
// and the news key on every one since Phase 11. Appending to either list would
// seed these on fresh installs only, and on exactly the upgrades that want
// them, never.
const keys = [coreRules.SEEDED_KEY, coreRules.NEWS_SEEDED_KEY, coreRules.EVENT_SEEDED_KEY]
assert.equal(new Set(keys).size, 3)
})