From e12cc0dbb1dc1d3f8f12474c8c1ffb1d5b3db858 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 13:06:06 -0500 Subject: [PATCH] =?UTF-8?q?docs(events):=20Phase=2010=20as=20built=20?= =?UTF-8?q?=E2=80=94=20the=20integrations,=20and=20a=20ceiling=20per=20fir?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- website/BACKEND_DESIGN.md | 21 +++++-- website/ENGAGEMENT.md | 2 +- website/EVENTS.md | 66 ++++++++++++++++++-- website/EVENTS_PLAN.md | 126 ++++++++++++++++++++++++++++++++++++++ website/MODULE_API.md | 45 +++++++++++++- 5 files changed, 247 insertions(+), 13 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index aa22c86..ec9fa02 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1119,10 +1119,22 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m ### The six event tables — the engine's, game-agnostic (events phase 1) -Design of record: [`EVENTS.md`](EVENTS.md) §D. Nine core tables are specified there; **six land in -Phase 1** — the ones that do not depend on the module contract. `event_action_settings`, -`event_run_budget`, `event_run_resources` and `event_run_participants` arrive with the phases that -give them a writer, rather than as empty tables nothing reads. +Design of record: [`EVENTS.md`](EVENTS.md) §D, which is where the full column list of every event +table lives. **Six land in Phase 1** — the ones that do not depend on the module contract — and are +spelled out below. The rest arrive with the phase that gives each a writer, rather than as empty +tables nothing reads: `event_run_phase_gates` in Phase 5, `event_action_settings` and +`event_run_budget` in Phase 6, `event_run_resources` in Phase 8, and **`event_run_participants` in +Phase 10** — `run_id` + a module-opaque `member_key` that is `UNIQUE` together, a nullable `user_id` +that `SET NULL`s so a record of what happened survives an account deletion, a `DECIMAL(18,4)` score +and a `rank_at` written only when results are published. Core writes it and sources none of it: a +`member_key` → account mapping is one game's, and a module reports both halves on its action's +success envelope. + +Phase 10 also put two columns on tables that already existed — `event_runs.results_published_at` +(below) and a nullable `announce_jobs.run_id`, which is what lets an event announce a post the news +pipeline has already announced without either job standing on the other's toes. Everything that +means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means +the one with a NULL `run_id`. Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id, a `scope`, a resource kind and a budget dimension are opaque strings core stores and never @@ -1202,6 +1214,7 @@ makes a run reproducible and an audit answerable after the definition has moved | params | JSON NULL | | | rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history | | started_at / ended_at | DATETIME NULL | | +| results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one | | claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner | | started_by | INT NULL FK→users(id) ON DELETE SET NULL | | | last_error | VARCHAR(500) NULL | | diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 6a38c96..48871a3 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -4333,7 +4333,7 @@ worth confirming in Phase 4 rather than discovering in Phase 11. | **Welcome / first steps** after registration | `users.created_at` | ⚠ needs a time-based evaluator | email | delayed (e.g. +1 h) | | **Finish setting up** — no linked game account after N days | `shard_links` absence | ⚠ module-supplied predicate | email, in-app | delayed | | **We miss you** — no login in N days | `users.last_login_at` | ⚠ needs a scheduled sweep | email | monthly, hard cap | -| **Come back for X** — a scheduled event is starting | operator-authored | ❌ needs a manual/scheduled trigger type | email, push, in-app | scheduled | +| **Come back for X** — a scheduled event is starting | `event.run.started`, emitted by the event runner | ✅ **built — `EVENTS_PLAN.md` Phase 10**. It is a trigger AND a stream, which is what gives it push: the push channel is stream-keyed, so the six other `event.` triggers are email and in-app only | email, push, in-app | on the run | | **Your invite is about to expire** | `user_invites.expires_at` | ⚠ | email | delayed | | **Account dormant, scheduled for cleanup** | policy | ❌ no dormancy policy exists | email | scheduled, staged | diff --git a/website/EVENTS.md b/website/EVENTS.md index 703ceef..cd2cf0b 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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 +`.`, 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` diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9387115..9f109e2 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -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 `.` 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`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index cbb05d0..a3452d1 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -47,6 +47,13 @@ a human and publish results. > API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as > one version they read once, which was the argument for putting the lease declaration here in the > first place. +> +> **Phase 10 amended it a second time, under the same rule**, with `participants` on the success +> envelope and an optional narrowing `ceiling` on `ctx.events.emit`'s. `main` still declares 1.9.0, +> so 1.10.0 remains unshipped and the whole event contract — actions, budgets, leases, option +> sources, reconcile, participants — still reaches an author as one number. The `ceiling` member is +> the one of the two that widens something 1.9.0 already shipped, and it is additive and optional: +> a module that never passes it is emitting exactly what it emitted before. **Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`, `core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in — @@ -273,9 +280,28 @@ unchanged, and so is every rule about composition narrowing rather than widening **1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`, -`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Nothing was added -to `ctx`: an action is called BY core, so what a module needs from this contract it is handed in the -envelope rather than reaching for. +`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Almost nothing +was added to `ctx`: an action is called BY core, so what a module needs from this contract it is +handed in the envelope rather than reaching for — `ctx.events.reconcile()` (Phase 8) is the one +exception, because only the module knows when the game it talks to has restarted. + +Two members joined it in Phase 10, both on an envelope: + +- **`participants` on an action's SUCCESS envelope** (`EVENTS.md` §D, §J). An action may answer + `{ ok: true, participants: [{ memberKey, userId?, score?, meta?, joinedAt? }] }` and core records + them against the run, on both success shapes, beside `resources`. `memberKey` is required and + module-opaque; `userId` is optional and is the module's own answer to "is this player a website + account", because core cannot map one and a core that guessed would be one game's identity model + compiled into core. A bad entry is dropped and logged, never a retry: a retried step re-dispatches + a world write that already happened. +- **`ceiling` on `ctx.events.emit`'s envelope** (`EVENTS.md` §I). An optional audience ceiling for + THIS firing, which may only ever narrow: the send-time G24 gate applies `meet(declared, emitted)`, + so a rule wider than the meet is refused and one narrower is unaffected. Two incomparable ceilings + meet to null and every rule is refused, which is §5.1a rule 3's posture rather than a guess about + which branch was meant. The case that forced it is core's own — a rehearsal fires the same + lifecycle triggers as a real run and must not mail every subscriber — and it is on the shared + envelope rather than in `events/` because "this particular firing is narrower than the kind + usually is" is a fact any emitter can have. **1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. @@ -1020,6 +1046,19 @@ rather than implementation and belong here: rule generalises past that one pairing — an action is the near end of a call with a far end, and the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it is attempted once, declares 15000. +- **A module reports who took part on the envelope, and there is no other door.** `participants` + rides back from `perform()` exactly as `resources` does, on both success shapes — including + `await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is + therefore the only moment its participants can be recorded. There is deliberately 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 step claim. One step may report at most 5000, the same bound the + engagement engine puts on a list of users a caller may assert, and a member reported twice in one + step is recorded once with the duplicate named. +- **A declaration's ceiling bounds the kind of event; a firing's `ceiling` bounds the occasion.** An + emitter that already knows this particular firing must not reach as far as the declaration allows + passes one, and the gate takes the meet. It only narrows — passing a wider value changes nothing — + and passing something incomparable with the declaration refuses every rule rather than resolving + to either. See §1.1 under 1.10.0. - **`once`, on all four.** A batch is a module's complete statement about what it declares; a second call is a module changing its mind halfway through `register()` rather than adding to it. And they STAGE, like every registration above: a module that registers two budgets and then throws has left