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

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