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