docs(events): Phase 10 as built — the integrations, and a ceiling per firing #218
@@ -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 | |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user