docs(events): Phase 10 as built — the integrations, and a ceiling per firing

Five files. `EVENTS.md` §F gains "What Phase 10 settled" and §D/§J are corrected
to what was built; `EVENTS_PLAN.md` records the phase as built with its six
decisions, the live walk and the three defects it found; `MODULE_API.md` folds
two envelope members into 1.10.0 in place; `ENGAGEMENT.md` §8.5's "come back for
X" row is resolved; `BACKEND_DESIGN.md` gains the eleventh event table and the
two columns Phase 10 put on tables that already existed.

The four contract findings, three of which are rules rather than facts about
events:

- a firing may carry a ceiling of its own and it may only ever NARROW, applied
  at the send-time G24 gate as meet(declared, emitted). §I promised a rehearsal
  "ceilinged to staff" and nothing implemented it;
- two incomparable ceilings refuse rather than resolve, which is §5.1a rule 3
  reused rather than re-argued;
- a module reports participants on the success envelope and there is no other
  door;
- a trigger that is not also a STREAM has no push and says nothing about it, so
  every stream must be a trigger while a trigger need not be a stream.

And a trigger's `description` is read by a recipient, not only by an operator:
it is the structural projection's `intro` fallback, so a line naming its own
audience ends up in that audience's inbox.

- [x] AI-assisted: Claude Code (Opus 5).

Code: RunicGateway/website#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:06 -05:00
parent c2fba44ffd
commit e12cc0dbb1
5 changed files with 247 additions and 13 deletions

View File

@@ -1118,6 +1118,132 @@ type"* — is resolved in this PR.
**Verify:** `npm test`; a mail-catcher rig confirming an event announcement reaches email, in-app and
push; the ceiling on `run.failed` proved to exclude a moderator.
> **Built.** Seven `event.` triggers, two seeded rules, `event_run_participants`,
> `core.results.publish`, `core.announce.post`, and a narrowing ceiling on the emit envelope.
> **No route was added and nothing moved** — the whole surface is two more derived fields on a run.
> `ENGAGEMENT.md` §8.5's *"Come back for X — a scheduled event is starting"* row is resolved.
>
> **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; and §J's ceilings with two rules seeded rather than seven.
>
> **The phase's own defect, and it was a promise nothing kept.** §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 fix is a
> per-firing `ceiling` on the emit envelope, applied at the send-time G24 gate as
> `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than
> resolving to either, which is `segments.js`'s own posture. `events/announce.js` passes `'staff'`
> when `run.rehearsal`, so a rehearsal exercises the announce steps, the rules and the log lines —
> everything except the delivery it must not make.
>
> **Four things the tree corrected about the plan.**
>
> - **Core does not seed through `registerEngagementSeeds`.** That door is module-facing: it requires
> template keys namespaced `<owner>.` and rule trigger ids namespaced likewise, while core's own
> bodies live in `engagement/templateSeeds.js` and its rules in `engagement/coreRules.js` under a
> per-group one-shot settings key. Phase 10 uses core's mechanism, with a THIRD key — the rule
> `ENGAGEMENT.md` Phase 11 established, because appending to the Team or news list would seed these
> on fresh installs only and on exactly the upgrades that want them, never.
> - **The ceilings are §J's, not this section's.** The paragraph above reads as though all seven sit
> at `admin`; §J says six are public and only `run.failed` is. §J is right and this is now built
> that way: six at ceiling `authenticated` / default 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.
> - **The row is `ENGAGEMENT.md` §8.5, not §8.6.** §8.6 is `module-uo`'s trigger catalogue.
> - **Two rules are seeded, not seven.** All seven triggers are DECLARED, so an operator can write a
> rule against any of them; what is seeded is the pair somebody would otherwise build on the first
> day. Seven disabled rows would bury the two that matter, and `event.phase.changed` is the one
> most likely to be switched on by accident and then mail a player four times in an evening.
>
> **Two design notes worth carrying forward.**
>
> - **None of the six public triggers declares a `url` variable, and that is deliberate.** 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 a path that did not exist, and
> the template editor previewed a link that was dead in every mail it sent. Phase 14 adds the
> variable alongside the page it points at, which is a version bump. `event.run.failed` is the
> exception because `/admin/events/runs/:runId` exists today.
> - **`startsAtLabel` is a presentational fragment computed at the emitter**, which is
> `ENGAGEMENT.md` §4.6.1 convention 1 rather than a shortcut. `startsAt` is a `datetime` and the
> seam normalises it to an ISO string — right as data, unreadable in a sentence — and a template has
> no logic with which to format one. The zone is the SHARD's, because "8pm" means the shard's
> evening to everyone reading it. `hour12` is set explicitly: left to the `en-GB` locale, midnight
> renders "00:00" while the schedule editor beside it writes "12:00 AM".
>
> **Two traps found in the build, both silent.**
>
> - **`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 read off it would have reported every idempotent
> retried collect as a fresh participant. `record()` answers nothing instead; the caller already
> knows how many it was given. Third occurrence of this flag's class, after Engagement Phase 4a's
> cooldown.
> - **A session variable is not a transaction.** The obvious ranking — `SET @rk := 0` then
> `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY score DESC` — is wrong here in a way no test
> without a live database would catch: `query()` takes a connection from the pool per call and
> releases it, so the variable is set on one connection and read on whichever the next call gets.
> `ROW_NUMBER() OVER (…)` in a joined derived table needs no session state at all.
>
> **The live walk.** A real rig — MariaDB, the site with no module installed, mailpit as the relay —
> and a real two-phase event. `event.run.started` fired at the transition, one rule matched, and
> **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**, which is the
> single-token block convention working: this event belongs to no arc, so its line disappeared
> instead of rendering "Part of .". The unsubscribe link carried `event:1` as its scope.
>
> A rehearsal of the same definition then fired the same trigger, logged
> `{"trigger":"event.run.started","ceiling":"staff","because":"rehearsal"}`, and produced **zero
> outbox rows** where the real run produced three — the server log naming the refusal in as many
> words (*"rule audience exceeds its trigger ceiling - refusing … emitted: staff"*). That is the
> phase's headline safety property, proved rather than asserted, and the contrast with the real run
> is what makes it a ceiling rather than a broken emitter.
>
> A deliberately doomed run then failed on a default-off `core.lease`, and `event.run.failed` reached
> **the administrator's inbox and nothing else** — the player who had received both "starting"
> notices got no failure notice at all.
>
> `core.announce.post` queued a second job against a post that had already been announced: the
> event's job carried `run_id`, `posts.announce_job_id` still pointed at the news job,
> `findByPostId` still returned the news job, and after the event's job rolled up to `done` the
> post's `announced_at` still read the August date it was published on. The option source offered the
> published post and not the draft; the draft was refused terminally by both the dry run and the
> real one.
>
> And the two things no unit test could reach — `rankRun`'s window function and the upsert — were
> run against real MariaDB 11: four participants ranked `340 → 120.5 → 120.5 → -15` with the tie
> broken deterministically, identical on a second call, and a re-reported member updated its score
> and its `user_id` in place while `joined_at` and `rank_at` stayed exactly as they were.
>
> **The live walk's first finding, and it was a channel that reported success while reaching nobody.** The
> seeded `event.run.started` rule named `push`, because §8.5's row and this phase's own Ships line
> both do. On the rig every `event.` id offered only email and in-app on the preferences screen while
> `news.post` offered push — because `notificationChannelPrefs.catalog` grants the push channel only
> to registered STREAMS, `publishToUsers` joins `notification_subscriptions`, and that table is only
> written for a channel a user could switch on. So the tickle went to nobody, every time, and
> `pushChannel.deliver` still answered `ok: true, 'tickle published'`. **`event.run.started` is now a
> stream as well as a trigger** (org lead, 2026-09-04) — one toggle, on the one lifecycle moment
> worth waking a phone for — and the other six stay email and in-app deliberately.
>
> **Its second finding: a trigger's `description` is read by two audiences, and one of them is the
> recipient.** It is the rule editor's catalog text — and, through `projection.project`'s `intro`
> fallback, the body of every unauthored render through `notify.event` or `inapp.event`. So
> `run.failed`'s original line ended *"Staff-facing."*, and those words landed in an administrator's
> own inbox item. All five of the jargon-y ones were rewritten as prose a player can read
> (*"tearing down"*, *"ran to the end of its last phase"*, *"placed on the calendar"*). Who a trigger
> is for is said by its CEILING, which is the only place that can enforce it anyway. Same class as
> Phase 9's *"not retried"* clause and Phase 8's counter: the server was right and the screen was
> not.
>
> **A post may now have more than one announce job, and everything that meant "the post's job" still
> means the news one.** `announce_jobs.run_id` is nullable and `findByPostId` filters
> `run_id IS NULL`, so the post admin panel and its retry button are untouched; `posts.announce_job_id`
> is written only when the post has none, and `announced_at` is not stamped by a run's job — an event
> linking a three-week-old article must not rewrite when that article was announced.
---
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`)