feat(events): the integrations — lifecycle triggers, participants, results (Phase 10) #192

Merged
whitlocktech merged 1 commits from feature/events-phase-10 into edge 2026-09-04 18:24:46 +00:00
Member

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 because a failure names the deployment's own broken machinery. Every one keys its cooldown on the RUN, so a weekly event is not throttled by last week's. Two rules seeded, both off, under a third one-shot key — so a deployment that has already stamped the Team and news keys still receives them.

A narrowing ceiling on the emit envelope · participants on the action's success envelope · core.results.publish as an ordinary step · a nullable announce_jobs.run_id · core's own seed mechanism rather than the module-facing one · §J's ceilings with two rules seeded rather than seven.

A seventh followed from the walk: event.run.started becomes a notification stream as well as a trigger.

The phase's own defect was a promise nothing kept

EVENTS.md §I says a rehearsal runs for real "with announcements ceilinged to staff". A ceiling is declared on the TRIGGER, and a rehearsal fires exactly 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 it.

The emit envelope now takes an optional ceiling and the send-time G24 gate applies meet(declared, emitted). It only narrows; two incomparable ceilings meet to null and every rule is refused rather than resolving to either, which is segments.js's own posture reused rather than re-argued. A rehearsal therefore exercises the announce steps, the rules and the log lines — everything except the delivery it must not make.

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. (I called this a 1.11.0 bump when I asked; the rule says otherwise and the rule wins.)

Four things the tree corrected about the plan

  • Core does not seed through registerEngagementSeeds. That door is module-facing — template keys must be namespaced <owner>. — while core's own bodies live in engagement/templateSeeds.js and its rules in engagement/coreRules.js under a per-group one-shot key.
  • The ceilings are §J's, not the plan paragraph's. It reads as though all seven sit at admin; §J says six are public. §J is right.
  • The row is ENGAGEMENT.md §8.5, not §8.6. §8.6 is module-uo's catalogue.
  • Two rules seeded, not seven. All seven are declared, so an operator can write a rule against any; seven disabled rows would bury the two that matter, and phase.changed is the one most likely to be switched on by accident.

The live walk, and the three defects it found

A real rig — MariaDB, the site with no module installed, mailpit as the relay — and a real two-phase event.

The mail arrived: "The Yew Invasion is starting", headed with the event's own title, its summary beneath it, and the start time reading "Friday 4 September at 1:54 pm (America/New_York)" rather than an ISO string. The series line was absent — the single-token block convention working, since this event belongs to no arc. A rehearsal of the same definition then logged {"ceiling":"staff","because":"rehearsal"} and produced zero outbox rows where the real run produced three. A deliberately doomed run failed on a default-off core.lease and event.run.failed reached the administrator's inbox and no player's. core.announce.post queued a second job against an already-announced post without touching posts.announce_job_id, findByPostId or announced_at. And rankRun's window function and the participant upsert were run against real MariaDB 11: four participants ranked 340 → 120.5 → 120.5 → -15 with the tie broken deterministically and identically on a second call, and a re-report updated score and user_id in place while joined_at and rank_at stayed put.

None of the three was visible to a unit test.

  1. A channel that reported success while reaching nobody. The seeded run.started rule named push, because §8.5's row and the plan's Ships line both do. Push delivery joins notification_subscriptions, which is only ever written for an id the preferences screen offered push for — and notificationChannelPrefs.catalog offers push only for a registered stream. So the tickle went to nobody, every time, while pushChannel.deliver still answered ok: true, 'tickle published'. event.run.started is now a stream as well as a trigger; the other six are not, because push is the channel that says now and the rest are things a player reads when they next look. The rule this settles: every stream must be a trigger; a trigger need not be a stream.
  2. A trigger's description is read by a recipient, not only by an operator. 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. All five jargon-y lines were rewritten as prose. Who a trigger is for is what its ceiling says.
  3. affectedRows cannot tell an insert from an unchanged upsert. The connector sends CLIENT_FOUND_ROWS, under which an ON DUPLICATE KEY UPDATE that changes nothing answers 1 — the same as an insert. A { inserted } flag would have counted every idempotent retried collect as a fresh participant. Third occurrence of this flag's class, after Engagement Phase 4a's cooldown.

And one caught before it shipped: ranking with a session variable is wrong here, because query() takes a pool connection per call — SET @rk := 0 lands on one connection and the UPDATE reads another. A window function needs no session state at all.

Verification

  • npm test --prefix server1981 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 — with modules/uo installed the two route-manifest tests fail by construction on this machine.
  • npm test --prefix client — 362 pass, 0 fail. npm run build green.
  • Zero-line routes.manifest.json and routes.guards.json diffs.
  • Swagger and the engagement trigger manifest regenerated and committed.
  • The live walk above.

One thing for a reviewer, out of scope and deliberately 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/**, which is essentially every route description in the published OpenAPI document. 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.

  • AI-assisted: Claude Code (Opus 5).

Docs: RunicGateway/docs#218.

🤖 Generated with Claude Code

`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 because a failure names the deployment's own broken machinery. Every one keys its cooldown on the RUN, so a weekly event is not throttled by last week's. Two rules seeded, both off, under a third one-shot key — so a deployment that has already stamped the Team and news keys still receives them. ## Six decisions (org lead, 2026-09-04), all as recommended A narrowing `ceiling` on the emit envelope · participants on the action's success envelope · `core.results.publish` as an ordinary step · a nullable `announce_jobs.run_id` · core's own seed mechanism rather than the module-facing one · §J's ceilings with two rules seeded rather than seven. A seventh followed from the walk: **`event.run.started` becomes a notification stream as well as a trigger.** ## The phase's own defect was a promise nothing kept `EVENTS.md` §I says a rehearsal runs for real *"with announcements ceilinged to `staff`"*. A ceiling is declared on the TRIGGER, and a rehearsal fires exactly 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 it. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings meet to null and every rule is refused rather than resolving to either, which is `segments.js`'s own posture reused rather than re-argued. A rehearsal therefore exercises the announce steps, the rules and the log lines — everything except the delivery it must not make. `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. (I called this a 1.11.0 bump when I asked; the rule says otherwise and the rule wins.) ## Four things the tree corrected about the plan - **Core does not seed through `registerEngagementSeeds`.** That door is module-facing — template keys must be namespaced `<owner>.` — while core's own bodies live in `engagement/templateSeeds.js` and its rules in `engagement/coreRules.js` under a per-group one-shot key. - **The ceilings are §J's, not the plan paragraph's.** It reads as though all seven sit at `admin`; §J says six are public. §J is right. - **The row is `ENGAGEMENT.md` §8.5, not §8.6.** §8.6 is `module-uo`'s catalogue. - **Two rules seeded, not seven.** All seven are declared, so an operator can write a rule against any; seven disabled rows would bury the two that matter, and `phase.changed` is the one most likely to be switched on by accident. ## The live walk, and the three defects it found A real rig — MariaDB, the site with no module installed, mailpit as the relay — and a real two-phase event. The mail arrived: *"The Yew Invasion is starting"*, headed with the event's own title, its summary beneath it, and the start time reading *"Friday 4 September at 1:54 pm (America/New_York)"* rather than an ISO string. The series line was **absent** — the single-token block convention working, since this event belongs to no arc. A rehearsal of the same definition then logged `{"ceiling":"staff","because":"rehearsal"}` and produced **zero outbox rows** where the real run produced three. A deliberately doomed run failed on a default-off `core.lease` and `event.run.failed` reached **the administrator's inbox and no player's**. `core.announce.post` queued a second job against an already-announced post without touching `posts.announce_job_id`, `findByPostId` or `announced_at`. And `rankRun`'s window function and the participant upsert were run against real MariaDB 11: four participants ranked `340 → 120.5 → 120.5 → -15` with the tie broken deterministically and identically on a second call, and a re-report updated score and `user_id` in place while `joined_at` and `rank_at` stayed put. **None of the three was visible to a unit test.** 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5's row and the plan's Ships line both do. Push delivery joins `notification_subscriptions`, which is only ever written for an id the preferences screen offered push for — and `notificationChannelPrefs.catalog` offers push only for a registered **stream**. So the tickle went to nobody, every time, while `pushChannel.deliver` still answered `ok: true, 'tickle published'`. `event.run.started` is now a stream as well as a trigger; the other six are not, because push is the channel that says *now* and the rest are things a player reads when they next look. The rule this settles: **every stream must be a trigger; a trigger need not be a stream.** 2. **A trigger's `description` is read by a recipient, not only by an operator.** 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. All five jargon-y lines were rewritten as prose. Who a trigger is for is what its **ceiling** says. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, under which an `ON DUPLICATE KEY UPDATE` that changes nothing answers 1 — the same as an insert. A `{ inserted }` flag would have counted every idempotent retried collect as a fresh participant. Third occurrence of this flag's class, after Engagement Phase 4a's cooldown. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — `SET @rk := 0` lands on one connection and the `UPDATE` reads another. A window function needs no session state at all. ## 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 — with `modules/uo` installed the two route-manifest tests fail by construction on this machine. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` and `routes.guards.json` diffs. - Swagger and the engagement trigger manifest regenerated and committed. - The live walk above. ## One thing for a reviewer, out of scope and deliberately 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/**`, which is essentially every route description in the published OpenAPI document. 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#218. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-04 18:07:38 +00:00
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
7d3d6d5abd
`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)
whitlocktech merged commit aba8d1e43a into edge 2026-09-04 18:24:46 +00:00
whitlocktech deleted branch feature/events-phase-10 2026-09-04 18:24:47 +00:00
Sign in to join this conversation.
No description provided.