The docs half of Event System Phase 2. Pairs with `RunicGateway/website` `feat/events-phase-2`. EVENTS.md gains what the runner settled: the parked step (`running` with a NULL lease), the two success-envelope members `await: 'human'` and `holdFor`, the answer for a run whose concurrency key is held, `n` in §L's `retry(n)`, the rule that all three `on_failure` dispositions write the step `failed`, and the health transition on the first retry rather than the eventual failure. EVENTS_PLAN.md marks Phase 2 complete and records the four org-lead decisions and the three things the build settled on its own. Co-Authored-By: Claude <noreply@anthropic.com>
660 lines
37 KiB
Markdown
660 lines
37 KiB
Markdown
# The Event System — phased implementation plan
|
||
|
||
**Derived from** [`EVENTS.md`](EVENTS.md) revision 5, which is the design of record. This document
|
||
decides *order*: what lands in which pull request, what each one ships on its own merit, and how each
|
||
is proved. It re-specifies nothing — where this and `EVENTS.md` disagree, `EVENTS.md` wins and this
|
||
file is the one with the bug.
|
||
|
||
**Seventeen phases, P0–P16.** Every phase is independently shippable and leaves the site working.
|
||
**No phase is gated any more.** P11 and P12 hung on `EVENTS.md` §N1 (the `ADMIN_CONTROLS.md` §8
|
||
amendment); §N1 and the other ten **were answered by the org lead on 2026-09-01**, which closes P0
|
||
and lifts the gate. They stay deliberately late all the same — they are the phases that reach into
|
||
the world, and they belong after the ledger that makes them safe. Everything else — the engine, the
|
||
scheduler, the calendar, the conditions, the caps, the module contract, the ledger, the first wave of
|
||
UO actions, the integrations, the authoring UI, the public surface — needed no decision beyond P0.
|
||
Those fourteen phases reach the game only to *announce*, over verbs the write plane already carries; nothing in them creates or
|
||
changes a thing in the world.
|
||
|
||
---
|
||
|
||
## Before anything: three facts about the ground
|
||
|
||
**1. `edge` is free, and it is stale.** The engagement workstream is fully cut over as of 2026-09-01 —
|
||
website `main` at `6331b36` (engagement Phase 14, retention), docs `#205` merged, `module-uo`'s `ci/core-ref.json`
|
||
re-pinned to `main`. No pull request is open in any of the six repos bar an automated
|
||
`PROJECT_TREE.md` sync.
|
||
|
||
But `edge` is **0 commits ahead of `main` and several behind it** in `website`, `docs` and
|
||
`module-uo` — the cutover merged `edge` into `main` and nothing moved `edge` afterwards. So the first
|
||
act of P1 is to **reset each `edge` to its `main`**, not to branch off it as it stands; branching off
|
||
a stale `edge` would silently revert the engagement retention work the moment this workstream cut
|
||
over. Verify per repo before starting:
|
||
|
||
```bash
|
||
git rev-list --count origin/main..origin/edge # must be 0
|
||
git rev-list --count origin/edge..origin/main # if > 0, edge is stale — reset it
|
||
```
|
||
|
||
**2. A `MODULE_API_VERSION` bump turns the integration kit red, on purpose.** `ci/core-ref.json` pins
|
||
a `main` sha and `checkCoreApi.js` asserts **equality** with what that sha declares. P7 bumps the
|
||
contract to 1.10.0, so the kit goes red from P7 until the cutover re-pins it. That is the mechanism
|
||
working — it forces someone to re-read the chapters — and it must be stated in P7's PR body so nobody
|
||
"fixes" it. The same is true of `module-uo`'s frozen-manifest check for the length of the `edge`
|
||
window.
|
||
|
||
**3. `servuo-plugins` has no CI build.** The plugin compiles only inside ServUO, and the dynamic
|
||
rebuild can silently reload a stale `Scripts.dll` — so "it booted clean" is not evidence the new code
|
||
is live. Every plugin-touching phase (P11, P12) verifies against the local tree at
|
||
`C:\Users\colby\Desktop\ServUO` with a build-offline-first step, and confirms new code is live rather
|
||
than trusting a clean boot.
|
||
|
||
---
|
||
|
||
## Cross-cutting obligations, every phase
|
||
|
||
Not repeated per phase below. A PR that skips one of these is not done.
|
||
|
||
| Obligation | Command / rule |
|
||
| --- | --- |
|
||
| `docs/` updated in the same PR | CLAUDE.md: a code change is not complete until `docs/` reflects it |
|
||
| Server tests | `cd website/server && npm test` |
|
||
| OpenAPI regenerated when a route changed | `npm run swagger` → committed `swagger/swagger-output.json` |
|
||
| Route manifest proves no URL moved | `npm run routes:manifest`, zero-line diff in the PR |
|
||
| No game vocabulary in core | `npm run check:modules` (website root) |
|
||
| module-uo, when touched | `npm run check:imports`, `npm run check:swagger`, `npm run build --prefix client` **before** `npm run check:externals`, then both test suites |
|
||
| Conventional Commits + AI disclosure | `type(scope): summary`; `Co-Authored-By:` trailer; tick the PR-template box |
|
||
| Branch | from an up-to-date `edge`, never a stale one |
|
||
|
||
---
|
||
|
||
## Ordering rationale — why this shape
|
||
|
||
Three choices in the ordering are deliberate and worth stating, because the obvious alternative is
|
||
wrong in each case.
|
||
|
||
**A demoable surface lands at P3, not at P13.** The rich authoring UI is late (P13) and a *minimal*
|
||
admin surface is early (P3). Splitting the UI in two looks like duplicated work and is not: this
|
||
project reviews in a browser — the org lead's own review stack exists for exactly that — and ten
|
||
backend phases with nothing to click is ten phases without feedback. P3 is a list, a form, a start
|
||
button and a run console. P13 is the timeline editor, the option-source dropdowns and the cap meter,
|
||
built once the schemas they render actually exist.
|
||
|
||
**The module contract (P7) comes after the engine, not before it.** The temptation is to design the
|
||
seam first. But `registerEventActions` is shaped by what the runner actually needs to hand an action
|
||
and what it does with the answer, and both are unknown until P2 and P6 are built. Core registers its
|
||
own `core.announce` / `core.wait` / `core.cue` through the *same* registry from P1 — exactly as
|
||
`registries.registerCore()` already does for streams and triggers — so the seam is exercised on every
|
||
boot long before a module uses it, and P7 is a generalisation of something working rather than a
|
||
guess.
|
||
|
||
**Caps (P6) come before the module contract, and before any world write.** A cap is cheap to add to an
|
||
empty system and expensive to retrofit onto a live one, and it is the single control that bounds the
|
||
two failures scheduling makes worse: a typo and a compromised session. P6 is a small phase placed
|
||
early on purpose.
|
||
|
||
---
|
||
|
||
## The phases
|
||
|
||
### Phase 0 — Design of record (`docs`)
|
||
|
||
> **Complete, in two pull requests.** The first introduced this file and `EVENTS.md`; the second
|
||
> recorded the eleven decisions and amended the two documents they reach into. **All of §N1–N11
|
||
> were answered by the org lead on 2026-09-01**, before any code, so nothing below is gated.
|
||
|
||
Land `docs/website/EVENTS.md`, and answer §N1–N11. Two of those answers have consequences outside this
|
||
document and should be written where they will be found:
|
||
|
||
- **N1** is an amendment to [`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) §8, recorded
|
||
there as an amendment with its date and reasoning — **not** a silent reversal. §8 is cited by name
|
||
in four places; a decision that contradicts it and leaves it standing is worse than either decision.
|
||
- **N3** (does the event runner become the `kind: 'scheduled'` evaluator) closes
|
||
`ENGAGEMENT.md` §7.1 Q6, which currently reads "no evaluator yet". If the answer is yes, that row
|
||
changes in the same PR.
|
||
|
||
**Ships:** the design of record, and two documents that stop contradicting each other.
|
||
**Verify:** `docs` CI link check; every relative link resolves.
|
||
|
||
> **As answered, 2026-09-01.** Nine of the eleven went the way `EVENTS.md` §N recommended. **Two did
|
||
> not, and each changes a phase below.**
|
||
>
|
||
> - **N2 — starting a run is `admin` only.** A moderator gets live control of a run already in
|
||
> flight — cancel and abort — and nothing more. Starting commits the deployment to everything a
|
||
> definition contains, unattended; cancelling is incident response, and gating the stop button on
|
||
> the same role as the start button would behave badly in exactly the case moderators exist for.
|
||
> **P3 and P6 carry this**, and `EVENTS.md` §K's table is the normative statement of it.
|
||
> - **N4 — this deployment is single-instance, and not planned to change.** **P2 does not build the
|
||
> `--scale app=2` test.** Every claim path is built exactly as specified regardless: the unique
|
||
> index and the CAS equally protect a tick that overruns into the next one, and the lease and its
|
||
> reclaim recover a step whose process died mid-dispatch. The multi-instance property becomes
|
||
> true-by-construction rather than proved; `EVENTS.md` §E records what to build first if this
|
||
> deployment is ever scaled or acquires a rolling deploy.
|
||
>
|
||
> **N1 was taken in full, item grants included** — a reversal of *two* separate lines in
|
||
> `ADMIN_CONTROLS.md` §8 rather than one, and the amendment at §8a says both out loud.
|
||
|
||
---
|
||
|
||
### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`)
|
||
|
||
> **Complete.** `edge` in `website` and `docs`. Six tables, thirteen routes, the action registry with
|
||
> core as its first registrant, and 44 tests. **Nothing dispatches** — a run row is created and stays
|
||
> `scheduled`, which is this phase's correct answer and is rendered as such.
|
||
>
|
||
> **Four things the build settled that the plan had left open, each recorded in `EVENTS.md`:**
|
||
>
|
||
> - **`event_definitions` gained a `spec` column.** §D's column list does not name one, because §D
|
||
> describes what a published event is made of. But "editing a draft is free; no version exists yet"
|
||
> means the working copy has to live somewhere, and it cannot be an `event_versions` row: that table
|
||
> is immutable and a run pins one. Publishing copies the column into a version and leaves it as the
|
||
> next draft.
|
||
> - **The spec validator must accept its own output**, and a test found it did not. `validate()` adds
|
||
> `actionVersion` and `dormant`, then refused them as unknown keys on the next call — which would
|
||
> have made the *second* save of any definition, and publish's own re-validation, impossible. Both
|
||
> are now accepted and recomputed rather than trusted.
|
||
> - **A param's `example` is required**, on optional params too, matching `registerEventTriggers`. It
|
||
> is the authoring form's placeholder and there is no other source for one.
|
||
> - **Two routes the §API-surface table did not name**: `GET /admin/events/:id` (the list serves a
|
||
> summary; the editor needs the tree) and `GET /admin/events/series` (a form cannot offer a value it
|
||
> cannot enumerate). Both are staff reads over data the list already exposes.
|
||
>
|
||
> **Two deliberate absences, both stated so a reviewer does not read them as gaps.** The live run
|
||
> controls and `verify` are not stubbed — nothing is in flight until P2, and a control that answers
|
||
> `200` and does nothing is worse than one that is not there. And core's three `perform()` bodies
|
||
> answer `{ ok: false, retry: false }` rather than `{ ok: true }`: `ok: true` on an action that did
|
||
> nothing is a recorded world change that did not occur, which is the exact mistake §F's failure
|
||
> default exists to prevent.
|
||
>
|
||
> `registerEventActions` is on the staging area and reachable **only** by `registerCore()` — the
|
||
> loader builds its own `api` facade and has no method that delegates to it, so no module can call it
|
||
> yet and `MODULE_API_VERSION` is untouched. P7 adds that facade and makes the bump.
|
||
|
||
The six tables that do not depend on the module contract: `event_definitions`, `event_series`,
|
||
`event_versions`, `event_runs`, `event_run_steps`, `event_run_log`. Admin CRUD, publish (which
|
||
snapshots a version), archive. `router/v1/admin/events.router.js` + `events.controller.js`, models as
|
||
`.model.js` / `.db.js` pairs under `model/events/`.
|
||
|
||
**The registry lands here, with core as its first registrant.** `modules/registries.js` gains
|
||
`registerEventActions` staging and commit, and `registries.registerCore()` registers three core-owned
|
||
actions: `core.announce` (post to an announce leg / broadcast target), `core.wait` (a timed no-op) and
|
||
`core.cue` (post an instruction and wait for a human). None of them execute yet — P2 is what runs a
|
||
step — but the registry, the id grammar, the risk classes and the param validation are all live and
|
||
exercised on every boot.
|
||
|
||
**Ships:** nothing user-visible; the site is unchanged. A run row can be created and stays
|
||
`scheduled` forever, which is correct for this phase and must be visible as such rather than looking
|
||
broken.
|
||
**Verify:** `npm test`; the registry's collision and validation paths tested the way
|
||
`registries.js`'s existing members are; routes manifest and swagger regenerated.
|
||
|
||
**Trap:** `register()` must not touch the database (`MODULE_API.md` §2.2) — `routeManifest.js` and
|
||
`swagger.js` both require `app.js` against a dead pool. Core's own action registration is subject to
|
||
the same rule.
|
||
|
||
---
|
||
|
||
### Phase 2 — The runner (`website`)
|
||
|
||
> **Complete.** `edge` in `website`. The eighth poller, the two CAS claims, the lease and its
|
||
> reclaim, the grace window, and the three core actions given real bodies. **A published event
|
||
> started from the existing run route now announces, waits and completes on its own** — the phase's
|
||
> shipped claim, and it adds no routes to do it.
|
||
>
|
||
> **Four things the org lead settled that the plan and §E had left open** (2026-09-02), each written
|
||
> into `EVENTS.md`:
|
||
>
|
||
> - **A parked step is `running` with a NULL lease.** `event_run_steps.status` has no state for
|
||
> "waiting on a human", and adding one would be a table ALTER that `CREATE TABLE IF NOT EXISTS`
|
||
> never delivers to an existing deployment. So the reclaim was written to take back only a lease
|
||
> that is **non-NULL and expired**, and a NULL one means parked. A cue posted on Friday is still
|
||
> waiting on Monday.
|
||
> - **Two success-envelope members, not two special cases.** `{ ok: true, await: 'human' }` parks;
|
||
> `{ ok: true, holdFor: <seconds> }` finishes and delays what follows. The runner never names an
|
||
> action id, and Phase 7 hands a module the same door.
|
||
> - **A run whose concurrency key is held stays `scheduled`** and lets its own grace window decide,
|
||
> rather than failing at once or queueing indefinitely.
|
||
> - **`n` in §L's `retry(n)` is a runner constant** — `EVENT_STEP_MAX_ATTEMPTS`, 3, with a flat
|
||
> 60s backoff — rather than a column or a spec field.
|
||
>
|
||
> **Three things the build settled on its own, all worth a look:**
|
||
>
|
||
> - **All three `on_failure` dispositions write the STEP `failed`.** The disposition governs the RUN.
|
||
> `skipped` is left for a human's skip control in Phase 3, because a status meaning both "nobody ran
|
||
> this" and "this failed and we moved on" makes the console's summary line unreadable.
|
||
> - **A live lease is not re-enterable, not even by the process that took it.** The first draft of
|
||
> `claimTick` carried an `OR claimed_by = ?` escape for a tick re-entering its own claim — which is
|
||
> precisely the overrun this phase's CAS is meant to protect against, since `setInterval` fires
|
||
> whether or not the last callback returned. The clause is gone, a `releaseClaim` hands a still-
|
||
> in-flight run back at the end of a tick (without it every `core.wait` would become
|
||
> `max(wait, leaseMs)`), and an in-process `ticking` guard skips an interval that would overlap.
|
||
> - **A wait as the last step of a phase holds the NEXT phase.** The first implementation set the
|
||
> following step's `due_at` and stopped there, so a trailing wait — "announce, wait five minutes,
|
||
> then phase 2" — silently meant nothing, because the next phase's steps are not materialised until
|
||
> the run enters it. The instant is now carried across the boundary. Found by writing the test, and
|
||
> the test was re-run against the unfixed code to confirm it fails.
|
||
>
|
||
> **What "materialise" means here.** The spec validator accepts `kind: 'manual'` alone until Phase 4,
|
||
> so there is no recurrence to expand — this leg builds the half that is already real, the grace
|
||
> window, and Phase 4 adds the expansion above it.
|
||
>
|
||
> **Verify, as run.** `npm test` — **1682 tests, 1638 pass, 43 skipped, 1 fail**, and that one is
|
||
> `engagementManifest.test.js`, pre-existing and environmental (`engagement-triggers.json` is CRLF in
|
||
> a Windows tree under `core.autocrlf=true` while the generator writes LF; content identical, green on
|
||
> CI, confirmed still failing with this branch stashed). 39 new tests across `eventRunner.test.js` and
|
||
> `eventRunnerSql.test.js`; the Phase 1 test asserting core's placeholders refused is replaced rather
|
||
> than deleted, because half of what it proved still holds. `routes:manifest` and `swagger`
|
||
> regenerated to a **zero-line diff** — the runner has no surface. `check:modules` clean.
|
||
>
|
||
> **Trap for anyone running the suite on this machine:** `server/modules/uo` is installed here, so the
|
||
> core suite and both generators need an empty `MODULES_DIR`. Without it `routeManifest.test.js` fails
|
||
> on a difference that is the module's, not the branch's.
|
||
|
||
`utils/eventRunner.js`, the eighth poller: same `setInterval` + `unref()` + `stop()` shape as the
|
||
other seven, wired into `server.js`'s start and shutdown beside `engagementWorker`.
|
||
|
||
Its tick, in order: **materialise** due occurrences (`INSERT IGNORE` against
|
||
`UNIQUE (definition_id, scope, scheduled_for)`); **advance** runs (CAS `scheduled → starting`,
|
||
`running → ending`, materialise the phase's steps); **drain** due steps (CAS `pending → running` with a
|
||
lease, dispatch, classify, record). Executes the three core actions from P1.
|
||
|
||
Also here: `missed` and the grace window, the lease and its reclaim, `concurrency_key` rendered from
|
||
run params, and `health` as a column separate from `status`.
|
||
|
||
**Ships:** a manually started event that broadcasts, waits, and completes. Demoable over curl.
|
||
**Verify:** `npm test`. **No two-instance test** — §N4 settled this deployment as single-instance, so
|
||
the `--scale app=2` rig the engagement workstream used is not built here. The claim paths are still
|
||
built exactly as `EVENTS.md` §E specifies, and they are still the point of the phase: they are what
|
||
protects a tick that overruns into the next one, and what recovers a step whose process died
|
||
mid-dispatch. Test both in-process. The decision is not licence to drop a CAS.
|
||
|
||
> **As built:** the in-process half is `eventRunner.test.js`, and the statements themselves are proved
|
||
> against a real MariaDB in `eventRunnerSql.test.js` — which SKIPS when there is none, so CI stays
|
||
> green without a database. That second file exists because of what engagement Phase 4a found: a
|
||
> cooldown claim that was green against its stub and always allowed the send against a real server,
|
||
> because the connector defaults `foundRows: true` and a no-op UPDATE reports 1 rather than 0. A stub
|
||
> can only ever agree with whoever wrote it. Run it with:
|
||
>
|
||
> ```bash
|
||
> DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=… node --test test/eventRunnerSql.test.js
|
||
> ```
|
||
|
||
**Two traps, both already paid for once in this codebase.**
|
||
- **A reclaim must not reset `attempts`.** Engagement Phase 14's defect: a sweep that returned every
|
||
stale row to its start state made `MAX_ATTEMPTS` unreachable, so the row cycled forever, never
|
||
terminal, therefore never retention-eligible.
|
||
- **The unique index, not the claim, is what prevents a double run.** The claim decides *who*
|
||
advances an occurrence; the index is what stops two existing.
|
||
|
||
---
|
||
|
||
### Phase 3 — The minimal admin surface (`website`)
|
||
|
||
`client/src/routes/admin/views/EventsAdmin.jsx`, `EventEditor.jsx`, `EventRun.jsx`, plus the nav rows.
|
||
A list with state and next occurrence; a create/edit form; publish; start now; cancel; and a run
|
||
console showing the phase, the step list with status and attempts, and the log.
|
||
|
||
**Publish and start now are `admin` only from this phase, not from P6** (§N2). Cancel is `admin` +
|
||
`moderator`. The routes get their real gates here even though the switchboard they will eventually
|
||
consult does not exist yet — a button that is admin-only later and open now is a gate nobody
|
||
notices was missing.
|
||
|
||
The spec is edited as **structured fields for the parts that exist** (name, description, schedule,
|
||
phases with their steps) and the step's params as a raw JSON field — a deliberate placeholder that P13
|
||
replaces with the schema-driven editor. Say so in the UI, so it does not read as the finished thing.
|
||
|
||
`core.cue`'s confirm button lands here, which is what makes the GM cue usable.
|
||
|
||
**Ships:** an admin can author, schedule, start and watch an event that announces things and cues a
|
||
human. **This is the first phase with a demo**, and it is the one to put in front of the org lead
|
||
before building further.
|
||
**Verify:** the client test suite; a browser walk on the local review stack.
|
||
|
||
---
|
||
|
||
### Phase 4 — Schedule, recurrence and the calendar (`website` + `docs`)
|
||
|
||
The closed recurrence shapes — `once`, `weekly`, `monthly` (nth weekday), `manual` — computed in the
|
||
definition's **IANA timezone** and stored as UTC in `scheduled_for`. `event_series` becomes usable: a
|
||
definition may belong to a series, and the series has an ordering.
|
||
|
||
An admin calendar view (month + list), filtered by state, scope and series.
|
||
|
||
**Ships:** recurring and scheduled events, and the calendar that replaces the thing this feature
|
||
exists to replace.
|
||
**Verify:** `npm test` with **DST-crossing cases as explicit fixtures** — a Friday 20:00 event in
|
||
`Europe/Berlin` computed across the March and October transitions, and one in a zone with no DST at
|
||
all.
|
||
|
||
**Trap:** the temptation is a cron string. There is no cron parser in the server dependency tree, the
|
||
only precedent is in the bot (a different process), and a cron expression is the one field an operator
|
||
cannot proofread. Closed shapes render as a form.
|
||
|
||
---
|
||
|
||
### Phase 5 — Conditions and phase advancement (`website` + `docs`)
|
||
|
||
Phase advance on `{ after: '30m' }` and on `{ on: '<triggerId>', where: <conditions>, count: n }`,
|
||
reusing `engagement/conditions.js` **unchanged** — its grammar, its type checking against the
|
||
declaration, its depth and list bounds, and its operator labels.
|
||
|
||
The runner subscribes to the trigger stream it already has; `event_run_log` gains
|
||
`condition.evaluated` rows for both outcomes.
|
||
|
||
**The diagnosis panel lands here**, and it is the phase's real deliverable: the operator question
|
||
*"why didn't phase 3 start?"* answered in the condition builder's own words — `gte` renders as *"is at
|
||
least"*, `present` as *"is present"* — with the tally, the elapsed time and the last related event.
|
||
|
||
**Ships:** multi-phase events that advance on what happens in the game rather than only on a clock.
|
||
**Verify:** `npm test`; a rig run where a phase legitimately does not advance, confirming the panel
|
||
explains why without a server log.
|
||
|
||
**Trap:** a condition is validated at **save** against the trigger's declaration, with the offending
|
||
variable named — not at evaluation. A predicate that silently reads `undefined` is a phase that
|
||
silently never advances, and the day you find out is the night of the event.
|
||
|
||
---
|
||
|
||
### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`)
|
||
|
||
`event_action_settings` (one row per registered action, everything above `notify` disabled by default)
|
||
and `event_run_budget` with the conditional increment:
|
||
|
||
```sql
|
||
UPDATE event_run_budget SET consumed = consumed + ?
|
||
WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
|
||
```
|
||
|
||
A breach is `refused` — the step does not run, does not retry, and is surfaced to the author with the
|
||
dimension and the numbers, because "you asked for 40 and this deployment allows 30" is an authoring
|
||
error, not an outage.
|
||
|
||
**The whole authorisation decision moves behind one function**, `mayInvoke(user, action, run)`: role,
|
||
enablement, cap, and the shard's own switch. Not for tidiness — it is what keeps an EM-style
|
||
delegation model a later option rather than a redesign.
|
||
|
||
The role split from `EVENTS.md` §K is applied to the routes here — completing what P3 started, and
|
||
including §N2's departure from the module-uo shape: **publishing and starting are `admin` only,
|
||
while cancelling and aborting a run in flight are `admin` + `moderator`**. Start and stop are
|
||
deliberately not the same gate.
|
||
|
||
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
|
||
anything.
|
||
**Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends;
|
||
a 403 walk across all four roles on every route.
|
||
|
||
---
|
||
|
||
### Phase 7 — The module contract (`website` + `docs` + a throwaway test module)
|
||
|
||
Generalise P1's registry into the public contract: `registerEventActions` with `cost`, `risk`,
|
||
`reversible`, `budgetMs`, `params` and `perform` / `revert`; `registerEventBudgets`;
|
||
`registerEventLeases`; and param **option sources**. `MODULE_API_VERSION` → **1.10.0**, with §2.4 and
|
||
§1.1 written the way every other member is.
|
||
|
||
Dispatch through the envelope: a rejected promise, a throw, a timeout, a non-object and a missing
|
||
`ok` are all read as `{ ok: false, retry: true }` — the inverse of `registerTeamProvider`'s default,
|
||
because here the expensive mistake is recording a world change that did not happen.
|
||
|
||
`verify: true` — dry run — is a required parameter a module must honour, with the test in the kit.
|
||
|
||
**Prove it with a throwaway module, not with module-uo.** A contract validated only against the module
|
||
it was carved out of has not been validated, and P9 should be the *second* consumer of this seam.
|
||
|
||
**Ships:** the seam. Core still does everything it did before.
|
||
**Verify:** `npm test`; the throwaway module exercising every failure shape; `check:modules` green.
|
||
|
||
> **This phase turns the integration kit red** and that is the mechanism, not a bug. `checkCoreApi.js`
|
||
> asserts equality against the pinned `main` sha; it stays red until the cutover re-pins it. Say so in
|
||
> the PR body.
|
||
|
||
---
|
||
|
||
### Phase 8 — The resource ledger, leases and cleanup (`website` + `docs`)
|
||
|
||
`event_run_resources` with `lease_until`, the `drifted` status, and
|
||
`UNIQUE (owner_module, kind, ref)` among non-reverted rows — which is what makes two events unable to
|
||
lease one target, and produces a `refused` step at authoring time rather than a corrupted baseline at
|
||
runtime.
|
||
|
||
**Record before confirm.** The step writes a `pending` row, dispatches, and promotes it on the answer.
|
||
Recording afterwards makes every object whose acknowledgement was lost invisible to cleanup forever —
|
||
so reverting a resource that does not exist must be a *success*.
|
||
|
||
Cleanup steps are **generated from the ledger** at teardown, on every terminal path — completion,
|
||
cancellation and abort alike. `cleanup_status` is its own column: a run reaches `completed` with
|
||
`cleanup_status = 'incomplete'` and stays on the admin screen, rather than being held `running`.
|
||
|
||
Reconcile-on-reconnect: the runner asks each ledgered resource's module what is still in force.
|
||
|
||
**Ships:** the safety property the whole world-write half depends on. Also useful on its own — the
|
||
platform gains a durable record of what it changed.
|
||
**Verify:** `npm test`; a rig run that kills the process mid-run and confirms cleanup completes on
|
||
restart; a run whose revert fails and stays visible.
|
||
|
||
---
|
||
|
||
### Phase 9 — UO wave 1: the actions that need no protocol change (`module-uo` + `docs`)
|
||
|
||
`module-uo` registers its first event actions over the write plane that already exists:
|
||
`uo.broadcast`, `uo.towncrier.post`, `uo.news.post`. Option sources answered **from the spawn
|
||
atlas** — `uo.options.regions`, `uo.options.landmarks`, `uo.options.creatures` — which cost nothing
|
||
new and work with the shard down.
|
||
|
||
**Ships:** the first end-to-end event against a real shard: scheduled, announced in-game and on the
|
||
site, cued to a GM, completed, recorded.
|
||
**Verify:** the whole rig — ServUO + sidecar + website — running a real two-phase event.
|
||
|
||
**Trap worth writing into the action declarations.** These three verbs have *different* idempotency.
|
||
`towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is
|
||
not** — a retry is a second announcement to everyone online — and there is no idempotency key on the
|
||
wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
|
||
declaration says why.
|
||
|
||
---
|
||
|
||
### Phase 10 — Integrations (`website` + `docs`)
|
||
|
||
Core registers its own `event.` triggers — `run.scheduled`, `run.started`, `phase.changed`,
|
||
`run.ending`, `run.completed`, `run.cancelled`, and `run.failed` at `ceiling: 'admin'` — with seeded
|
||
templates through the mechanism `registerEngagementSeeds` already provides. Events owns **none** of the
|
||
delivery.
|
||
|
||
`event_run_participants`, results publication, and the `core.announce.post` action that links an
|
||
existing post to a run and enqueues it through `announce_jobs` — so the in-game town crier and Discord
|
||
both come free as already-registered legs with retry and classification.
|
||
|
||
`ENGAGEMENT.md` §8.6's row — *"a scheduled event is starting · ❌ needs a manual/scheduled trigger
|
||
type"* — is resolved in this PR.
|
||
|
||
**Ships:** every announcement channel the platform has, for every event, per user preference.
|
||
**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.
|
||
|
||
---
|
||
|
||
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`)
|
||
|
||
**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked.
|
||
|
||
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a
|
||
repeat with the original result; a lease deadline timer that restores baseline **without being
|
||
asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind.
|
||
- **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar
|
||
and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to
|
||
compose.
|
||
- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served.
|
||
- **`installer`:** joins the phase because of the pairing above.
|
||
- **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`.
|
||
|
||
**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both
|
||
are safety properties, not features.
|
||
**Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new
|
||
code is live; a deliberate duplicate command; a lease whose website is killed before it expires,
|
||
proving baseline returns anyway.
|
||
|
||
---
|
||
|
||
### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`)
|
||
|
||
**§N1 answered 2026-09-01 — no longer gated**, and taken in full, so the item grant row below
|
||
stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and
|
||
either owned or borrowed:
|
||
|
||
| Verb | Owned or borrowed | Cap dimension |
|
||
| --- | --- | --- |
|
||
| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | `uo.creatures` |
|
||
| "Simple" boss variants, as event-owned creature templates | owned | `uo.bosses` |
|
||
| Oracle NPCs with scripted dialogue | owned | `uo.npcs` |
|
||
| Temporary gates | owned, with a deadline | `uo.gate.minutes` |
|
||
| Temporary decoration lockdown | owned | `uo.decor` |
|
||
| A live config value | **borrowed** — a lease | — |
|
||
| A property on an existing object | **borrowed** — a lease | — |
|
||
| Seasonal-event toggle | borrowed | — |
|
||
| World save | neither — a one-shot | — |
|
||
| Item grant | owned, `reversible: 'none'` | `uo.rewards` |
|
||
|
||
**The lease allowlist ships with a boot-time self-check.** 258 `Config.Get` call sites in ServUO split
|
||
between live reads and values cached at type initialisation, and a lease on the second kind applies
|
||
cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the
|
||
advertised catalog if it does not take — a capability that disappears loudly beats one that lies.
|
||
|
||
**Ships:** the invasion.
|
||
**Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown
|
||
back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming `drifted`
|
||
rather than a silent revert of their change.
|
||
|
||
---
|
||
|
||
### Phase 13 — The authoring UI proper (`website`)
|
||
|
||
Replaces P3's placeholders. The phase timeline (**not** a node graph — the condition grammar has no
|
||
branching and a canvas would advertise power the engine does not have); the step editor rendering each
|
||
action's declared params, with option-source dropdowns; the live cap meter; dry run; rehearsal.
|
||
|
||
**Ships:** an administrator can build the invasion without touching JSON, which is the acceptance
|
||
criterion the whole feature was asked for.
|
||
**Verify:** the client suite; a browser walk authoring a multi-phase event end to end with no raw JSON
|
||
at any point.
|
||
|
||
---
|
||
|
||
### Phase 14 — Public, player and mobile (`website` + `android-app` + `docs`)
|
||
|
||
`GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`,
|
||
`/player/events/history`, and an `events` capability string.
|
||
|
||
**One prerequisite fix, in this phase and not after it.** The Android app hardcodes
|
||
`api/v1/public/shard/stream` — a *module* path — and reads `/public/modules` nowhere, so it cannot
|
||
render a site whose module it has never heard of. That is a small fix and it blocks the app seeing
|
||
events at all.
|
||
|
||
**Ships:** the public calendar, event pages with their storyline and results, participation history,
|
||
and the app.
|
||
**Verify:** the client and app suites; an emulator walk.
|
||
|
||
---
|
||
|
||
### Phase 15 — Integration Kit chapter 5 (`integration-kit`)
|
||
|
||
An event-capable module. Teach and link out; re-specify nothing — `EVENTS.md` and `MODULE_API.md` stay
|
||
normative.
|
||
|
||
The four things a second module's author will get wrong, one paragraph each, all invisible until an
|
||
outage: the envelope's failure default, the idempotency passthrough, recording a resource **before**
|
||
confirming it, and under-declaring `cost`.
|
||
|
||
**Like Teams Phase 11, this cannot merge until the cutover exists** — the kit is pinned to a `main`
|
||
sha, and the contract it teaches is not on `main` until then.
|
||
|
||
---
|
||
|
||
### Phase 16 — Acceptance walk and cutover
|
||
|
||
**The walk first, against released artefacts, not a working tree.** The whole rig — ServUO, sidecar,
|
||
website, emulator — running a real multi-phase event, including three deliberate failures:
|
||
|
||
1. a mid-run process restart, proving the run resumes and no step double-executes;
|
||
2. a sidecar kill mid-phase, proving the run degrades rather than failing and world writes park;
|
||
3. a cap breach, proving `refused` and an author who is told why.
|
||
|
||
Then `edge` → `main`, in the order every previous cutover used: the protocol side first, the module,
|
||
core, docs, then the kit's re-pin and `runicgateway.com`.
|
||
|
||
**Two documents that are cutover-window work by construction.**
|
||
- **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until
|
||
the cutover lands. Same 12a/12b split the engagement workstream needed.
|
||
- **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new
|
||
subsystem is.
|
||
|
||
---
|
||
|
||
## What this plan does not do
|
||
|
||
Stated so the omissions are choices rather than oversights.
|
||
|
||
- **No node-graph editor** (P13). The engine has no branching.
|
||
- **No delegation, grants or proposal queue.** Permissions gate on the existing admin roles. P6's
|
||
`mayInvoke` keeps it a cheap later option.
|
||
- **No points.** No core points system exists and no write path to any loyalty board exists.
|
||
- **No event invoking another event.** It already works by composition — a second event's condition can
|
||
be `event.run.completed` — and a direct edge would need cycle detection and a story about cancelling
|
||
a parent.
|
||
- **No core SSE.** The run console polls. Adding a live channel to core is a larger change than this
|
||
feature needs, and if it is ever built it should be core infrastructure with module-uo's two streams
|
||
migrating onto it.
|
||
- **No mutation of shard-owned content without a baseline.** Loot tables and arbitrary
|
||
`[set` / `[get` / `[add` stay excluded.
|
||
|
||
---
|
||
|
||
## Dependency summary
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
P0["P0 · design of record"] --> P1["P1 · schema + registry"]
|
||
P1 --> P2["P2 · the runner"]
|
||
P2 --> P3["P3 · minimal admin UI<br/><b>first demo</b>"]
|
||
P3 --> P4["P4 · schedule + calendar"]
|
||
P2 --> P5["P5 · conditions + diagnosis"]
|
||
P2 --> P6["P6 · enablement + caps"]
|
||
P6 --> P7["P7 · module contract<br/>MODULE_API 1.10.0"]
|
||
P7 --> P8["P8 · ledger + leases + cleanup"]
|
||
P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
|
||
P5 --> P10["P10 · integrations"]
|
||
P9 --> P10
|
||
P0 -.->|"§N1 answered"| P11["P11 · protocol bump"]
|
||
P8 --> P11
|
||
P11 --> P12["P12 · UO world verbs"]
|
||
P7 --> P13["P13 · authoring UI"]
|
||
P4 --> P13
|
||
P10 --> P14["P14 · public + mobile"]
|
||
P13 --> P16["P16 · walk + cutover"]
|
||
P12 --> P16
|
||
P14 --> P16
|
||
P7 --> P15["P15 · kit chapter 5"]
|
||
P15 --> P16
|
||
```
|
||
|
||
**The critical path is P0 → P1 → P2 → P6 → P7 → P8 → P11 → P12 → P16.** Everything else can run
|
||
beside it. P3, P4, P5 and P10 are the phases that could be picked up by a second pair of hands without
|
||
blocking the spine.
|
||
|
||
---
|
||
|
||
## If only part of this gets built
|
||
|
||
Three defensible stopping points, in case scope has to shrink.
|
||
|
||
**Stop after P5** — a scheduled, multi-phase, condition-advanced event engine that announces through
|
||
core's own actions and cues a human for anything in-world. No module contract, no ledger, no protocol
|
||
change. This is a real product and it is roughly a third of the work.
|
||
|
||
**Stop after P10** — everything above plus the module seam, the ledger, the first UO actions and every
|
||
notification channel. The website orchestrates and announces; a GM does the target-driven parts
|
||
in-client, exactly as `ADMIN_CONTROLS.md` §8 argued they should. **This is the recommended minimum
|
||
viable scope**, and it was the scope that required no answer to §N1 at all. §N1 has since been
|
||
answered in full, so this is now a scope choice rather than a way around an open decision.
|
||
|
||
**Stop after P14** — everything, including the world verbs and the public calendar. P15 and P16 are
|
||
not optional if anything ships to `main`; they are the cost of the `edge` discipline.
|