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

@@ -415,7 +415,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
| `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. |
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. |
| `event_run_resources` | `run_id`, `step_id` **`SET NULL`**, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among the rows core still believes are ITS — see the amendment below | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. `@step` is a reserved `kind` core owns (rule 1, below); a module reporting one is refused. |
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. |
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
@@ -879,6 +879,60 @@ registered an action, and three of the four things it found are rules rather tha
current boot against a boot that ended hours ago and orphans it. A live resource core will never
take back, lost to a reconnect. Any module wiring this seam owes the same gate.
### What Phase 10 settled, wiring the engine to everything else
Phase 10 is the integrations — core's own `event.` triggers, `event_run_participants`, results
publication, and `core.announce.post`. Four things it settled, and three of them are rules rather
than facts about events.
- **A firing may carry a ceiling of its own, and it may only ever NARROW.** A trigger's declared
ceiling is a property of the KIND of event; a rehearsal is a property of the occasion, and the two
are different questions. §I promises an event can be "run for real with announcements ceilinged to
`staff`", and nothing implemented it — a rehearsal of a published event fires exactly the same
trigger as the real thing, so it would have mailed every subscriber. The emit envelope now takes an
optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It is a
MODULE_API change (1.11.0) because the envelope is a module's too, and the alternatives were worse:
a rehearsal that emitted nothing would be a rehearsal of everything except the announcements, and a
parallel set of `event.rehearsal.*` ids would make every operator write each rule twice.
- **Two incomparable ceilings refuse rather than resolve.** `meet('owner', 'staff')` is null — they
have no common descendant, which is the whole argument `modules/ceilings.js` is built on — and the
gate then refuses every rule. That is §5.1a rule 3's posture, reused rather than re-argued, and the
reason a narrowing ceiling could not simply be "the smaller of the two".
- **A module reports participants on the success envelope, beside its resources** (org lead,
2026-09-04). There is no `ctx.events.participants` 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 attendance at all. Core stores what it is told and sources nothing
`member_key` is module-opaque and `user_id` is filled in only by whoever knows the link table,
because a core that guessed would be one game's identity model compiled into core.
- **`affectedRows` cannot tell an INSERT from an unchanged upsert here**, and a "was this new" flag
read off it would have counted every idempotent retry as a fresh participant. The connector sends
`CLIENT_FOUND_ROWS`, under which an `ON DUPLICATE KEY UPDATE` that changes nothing answers 1 —
exactly what an insert answers. The recording path counts what it was given instead. Same family as
Engagement Phase 4a's `foundRows` trap, one flag along.
- **A trigger's `description` is read by a RECIPIENT, not only by an operator.** It is the rule
editor's catalog text and, through the structural projection's `intro` fallback, the body of every
unauthored render — so a line that names its own audience (*"Staff-facing."*) is a line that ends
up in that audience's inbox. Write it as prose the reader can read; who a trigger is for is what
its ceiling says, and the ceiling is the only place that can enforce it.
- **A trigger that is not also a STREAM has no push, and says nothing about it.** Push delivery joins
`notification_subscriptions`, which is only ever written for an id a user could switch push on for
— and `notificationChannelPrefs.catalog` offers the push channel only for registered streams. So a
rule naming `push` on a trigger-only id enqueues a tickle nobody can subscribe to, `publishToUsers`
finds no endpoint, and the send log records it `sent`. The seeded `run.started` rule named push
before the live rig caught it. **`event.run.started` is now a stream as well as a trigger** (org
lead, 2026-09-04) — the same-owner upgrade `news.post` already is — and the other six are not: push
is the channel that says *now*, and the rest are things a player reads when they next look. The
containment runs one way, and that is the rule this settles: **every stream must be a trigger**, or
it is a push toggle for something no rule can fire; a trigger need not be a stream.
**And one thing the tree corrected about this phase's own plan.** Core does not seed through
`registerEngagementSeeds`: that door is module-facing and requires template keys 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. Phase 10 uses core's mechanism, which is
also what keeps Engagement Phase 11's rule intact — one key per seed GROUP, so a deployment that has
already stamped the Team and news keys still receives these two.
### What is contract rather than implementation
**Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02).
@@ -1228,13 +1282,13 @@ rendering last-known state rather than an error.
| System | How Events connects | Status |
| --- | --- | --- |
| **Engagement** | Core registers triggers under `event.``run.scheduled`, `run.started`, `phase.changed`, `run.ending`, `run.completed`, `run.cancelled` public; `run.failed` at `admin`. Events owns **none** of the delivery. | clean fit |
| **Engagement** | Core registers triggers under `event.``run.scheduled`, `run.started`, `phase.changed`, `run.ending`, `run.completed`, `run.cancelled` at ceiling `authenticated` / default audience `subscribers`; `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN, so a weekly event is not throttled by last week's. **`run.started` is also a notification STREAM**, and it is the only one: push delivery is stream-keyed, so a trigger that is not one can be mailed and inboxed but never pushed. Events owns **none** of the delivery. **Built in Phase 10**, with two rules seeded — `run.started` and `run.failed` — and both, like every seeded rule, off. | built |
| **Scheduled triggers** | The event runner is the natural home for the `kind: 'scheduled'` evaluator ENGAGEMENT §7.1 Q6 deferred. One tick, two consumers, and it closes an open item rather than adding a second timer. | recommended |
| **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. | reuse the legs |
| **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. **Built in Phase 10**, on a nullable `announce_jobs.run_id`: a post may now have more than one job, and everything that means "the post's job" — the admin panel, its retry button, `announced_at` — still means the news one. | built |
| **Notifications** | Falls out of Engagement — email, in-app inbox, content-free push tickles, per preference, with suppression and the verification gate. Nothing new. | exists |
| **Teams** | "This Team's members" is already a registered audience, so a guild-scoped event is a rule's audience segment and needs no event-side feature. | free |
| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. | small |
| **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. | by publication |
| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; the surface that reads it is Phase 14's. | table built |
| **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication |
| **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module |
| **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out |
| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, an `events` capability string. **Prerequisite:** the app hardcodes `api/v1/public/shard/stream` — a module path — and reads `/public/modules` nowhere, so it cannot render a site whose module it does not know. | app fix first |
@@ -1592,6 +1646,8 @@ absent any more.** The option-source route answers a refusal with a `200`, which
about it worth stating twice: §F requires a source that cannot answer to degrade its field rather
than block the form, and a status code is how a client tells the two apart.
**Phase 10 added no route at all.** Its whole surface is two more derived fields on a run: `participants`, who took part as a module reported them — `memberKey` module-opaque, `userId` only where the module could link the player, `rank` null until results are published — and `resultsPublishedAt` on the run itself, which is on the run LIST as well, because *"which of last month's events still have no published results"* is a question about a list. A run whose participants are collected and unranked is a real and visible state rather than an error.
**Phase 8 added one route and one body field, and neither moved anything.** `cleanup` is the manual
retry; `cancel`'s `{ cleanup }` is the choice about what a cancelled run leaves behind. A run's
detail gained a fifth and sixth derived field: **`resources`**, the whole ledger with the `@step`