# 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: }` 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" meant in THIS phase.** The spec validator accepted `kind: 'manual'` alone, > 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`) > **Complete.** `edge` in `website`. Three screens, a nav group and **six live run controls** — the > routes Phase 1 left absent on purpose because nothing was in flight, and Phase 2 gave something to > act on. **An admin can now author, publish, schedule, start and watch an event that announces > things and cues a human, and a moderator can stop one that is going wrong.** This is the first > phase with a demo. > > **Four decisions the org lead settled (2026-09-02), all as recommended:** > > - **Six controls, not four and not eight.** `pause`, `resume`, `cancel` on a run; `confirm`, > `skip`, `retry` on a step. **`advance` is not built** — a phase today advances when its steps go > terminal, and the per-step skip already does that one step at a time, so a force-advance now would > silently change meaning under the operator when Phase 5 gives a phase an advance *condition*. > `cleanup` needs Phase 8's ledger. > - **Cancel takes `{ reason }`, not `{ cleanup, reason }`.** The flag arrives with the thing it would > act on. A `cleanup: false` that changes nothing is the "control that answers 200 and does nothing" > Phases 1 and 2 both refused. > - **Its own top-level nav group, staff-wide** (`admin`, `editor`, `moderator`) — not admin-only like > Engagement's. §K makes every read here `staff`, and the moderator's entire power over this feature > is the run console; hiding it from them would leave the one role that exists for incident response > unable to see the incident. The narrow gates are on the actions instead, and each button follows > the route it calls. > - **The params box is a raw JSON field with the action's declaration rendered beside it.** Both are > already in the catalog — name, type, required, description, example — so the placeholder is usable > without reading source, and it is captioned as a placeholder so it does not read as Phase 13's > schema-driven form. > > **Three things the build settled, and the first is a defect in shipped code:** > > - **A pause pressed mid-tick did nothing for up to 24 more steps.** `advanceRun` drains up to > `EVENT_STEPS_PER_TICK` steps from one run inside a single tick and only checked the run's status at > the top of it — so the whole value of a pause, that it takes effect *now*, was absent. The loop > re-reads the status between steps (`runsDb.statusOf`, one indexed column by primary key). Found by > writing the test; the test was re-run against the unfixed code to confirm it fails, and it does. > - **The retry guard was reading the wrong end of the phase.** The first draft asked for the lowest > `seq` that is not *settled*, which looks equivalent to "the step the run is stopped at" and is not: > `nextOpenStep` selects `pending` and `running` only, so the runner steps *over* a `failed` step. > A phase whose second step failed-and-skipped and whose fifth then failed-and-paused would have > offered retry on the second, re-queueing a row behind the runner's own cursor where it sits > `pending` for ever. The rule is now `MAX(seq) WHERE status <> 'pending'` — the furthest the phase > has reached — and the test that found it is the one that names the case. > - **Retry and resume are one control, because there is no state in which you would want half of > it.** Retry is legal only from `paused`, and a paused run is paused *at* that step; re-queueing > without resuming leaves the run exactly where it was with a second button to find. `attempts` > returns to zero: the ceiling bounds what the runner does **unattended**, and a named person > deciding once is the thing it is unattended from. That is not Engagement Phase 14's rule being > broken — that rule is about automatic *sweeps*. > > **Two smaller ones, taken as assumptions rather than asked:** the console polls every 5s while the > run is non-terminal and stops the moment it is not (§N5, poll not SSE — a run changes on a > fifteen-second tick and a console is a tab left open for two hours); and `confirm` takes an optional > note saying what was actually done in-client, which is kept on the step and in the log. > > **A cue nobody notices is a run that never advances**, and it looks perfectly healthy from the > outside — `running`, nothing failed. So `waitingSteps` is on the run LIST as well as the console, > as a derived count rather than a column, and the list leads with a banner naming every run that is > waiting on a person. > > **Verify, as run.** `npm test` — the pre-existing `engagementManifest.test.js` CRLF failure is the > only red, exactly as in Phase 2. **42 new tests**: `eventRunControls.test.js` (22, almost all of them > *refusals* — a control that works from a status it should not have is a staff member changing a live > world from a stale screen), 8 more in `eventRunnerSql.test.js` proving the four new statements > against a real MariaDB, 2 more in `eventRunner.test.js`, and `eventAuthoring.test.js` (20) on the > client. `routes:manifest` and `swagger` regenerated — **six routes added, none moved**. The client > builds. `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. **As built, the control set is six**: `pause`, `resume`, `cancel` on a run and `confirm`, `skip`, `retry` on a step, all `admin` + `moderator`. Every one of them is a compare-and-set on the status it may act from, never a read-then-write — the runner ticks every fifteen seconds, so a console rendered thirty seconds ago describes a run that has moved, and a control that checked in JavaScript and then wrote would race the tick it exists to interrupt. A refusal is a `409` naming the status the run is actually in, and the client models the same guards so a button the server will refuse is not offered in the first place. 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`) > **Complete.** `edge` in `website` and `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` is usable and managed inline on the calendar; an admin calendar > (month + list) filtered by state, scope and series. **An event now happens on its own.** No schema > change: Phase 1 built every column this needed. > > **The decisions the org lead settled (2026-09-02), all as recommended:** > > - **A fourteen-day materialisation horizon, with projections beyond it.** Inside it an occurrence is > a real row an operator can see, cancel and reschedule one at a time; beyond it the calendar > forecasts from the same arithmetic, so a monthly event is still visible three weeks out. Drawn > differently on purpose — acting on a forecast as though it were a booking would be the UI's fault. > - **Automatic expansion is at the EMPTY scope.** A fan-out across named scopes needs a registry of > what a scope *is*, which no phase owns yet; inventing one here would be a contract the modules were > never asked about. The admin's own start route still takes any scope. > - **DST: skip-forward, take-first.** A local time the spring gap swallows moves forward to the first > one that exists (02:30 becomes 03:00, not 03:30); an hour that happens twice takes the first. Both > are recorded as `detail.dstAdjusted`. **Neither rule ever drops an occurrence.** > - **`nth` is 1..4 plus -1 for "last".** There is no fifth, so there is no absent-occurrence case to > define — every month has a first through fourth of every weekday. `-1` is not a synonym for `4`. > - **Publishing re-pins the occurrences that have not started.** Asked mid-build, once the horizon > made it real: on the day an editor fixes a typo there are already fourteen days of rows carrying > the old spec. A pin makes a run that **has run** reproducible; one that has not begun has nothing > to reproduce. The alternative was worse than doing nothing — cancelling a stale occurrence leaves > its slot held in `uq_evrun_occurrence`, so it would not come back on the new version, it would > vanish. > > **Three things the build settled:** > > - **`now - grace` is the window start, not `now`.** An occurrence nobody ever materialised is never > invented retroactively — three days down must not manufacture three days of `missed` history no > operator could have seen. It does not need to: rows exist a fortnight early, so a real outage finds > them already there. **The horizon is what makes the missed sweep mean anything for a recurrence.** > - **Publishing is the schedule switch; archiving turns it off.** `ready` already means "a version has > been published and the schedule is live", so a second enabled flag would be another answer to a > question `state` answers. Expansion reads the **published version's** spec, never the working copy. > - **Series writes are `admin, editor`.** Naming an arc is authoring; §N2's narrow gate is about > committing the deployment to a run. > > **A defect this phase introduced into the test harness, and fixed.** Putting the expansion leg in > front of `tick()` made `eventRunner.test.js` reach the dead-port pool on every tick — the file passed > and took minutes. Stubbing `findSchedulable` there returned it to 0.45s. Worth naming because a suite > that is merely *slow* reads as a suite that is fine. > > **Verified:** `npm test` — **1768 tests, 1711 pass, 56 skipped, 1 fail**, that one the pre-existing > `engagementManifest.test.js` CRLF failure (`edge` before this branch: 1714/1662/51/1). **54 new > tests**, of which `eventRecurrence.test.js` (17) is the DST fixture set this plan asked for, and 5 in > `eventRunnerSql.test.js` prove `findSchedulable`, `listInWindow` and `repinScheduled` **against a > real MariaDB** (32/32 with a database, skipped without). Client: 354 pass. Four routes added, none > moved; `check:modules` and `check:hosts` clean; the client builds. **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. *Held: `events/recurrence.js` inverts `Intl.DateTimeFormat` rather than adding a date library, since Node already ships the tzdata one would vendor.* --- ### Phase 5 — Conditions and phase advancement (`website` + `docs`) > **Complete.** `edge` in `website` and `docs`. Phase advance on `{ after: '30m' }` and on > `{ on: '', where: , count: n }`, reusing `engagement/conditions.js` > **unchanged** — its grammar, its type checking against the declaration, its depth and list bounds, > and its operator labels. `event_run_log` gained `phase.gate`, `condition.evaluated` (written for > **both** outcomes) and `phase.advanced`. The diagnosis panel is the phase's real deliverable, and > `POST /admin/events/runs/:runId/advance` — absent since Phase 3 for want of a meaning — arrived > beside it. One new table, `event_run_phase_gates`. > > **The decisions the org lead settled (2026-09-02), all as recommended:** > > - **A new table, not a query over the log.** The tally, the entry time and the last related firing > are a row with an atomic conditional increment, the protection §E gives caps. Deriving them from > `event_run_log` would have been a JSON predicate no index supports, and it would have made the > retention sweep load-bearing for whether a phase advances. > - **A gate that never opens is HELD, and the run goes `stalled`.** No automatic advance, ever, and > no authored timeout: "what should happen when the world did not cooperate" is a decision an > operator makes live, not one an author guesses at months earlier. What the engine owes is > visibility — `EVENT_PHASE_STALL_MS` (1h) takes `health` to §E's third value, the first thing in > this system ever to write it, logged once. It has to be loud: **a held run keeps its concurrency > key**, so every later occurrence of that definition goes `missed` behind it. > - **Force-advance ships here, not in Phase 6.** A gate without an override is a panel that explains > a problem nobody can act on, and §K already had the gate written (`admin` + `moderator`). > - **The clock and the tally start at phase ENTRY**, not when the steps finish. `after: '30m'` means > thirty minutes from the moment the phase began whatever its dispatches took, and a firing during > the announce counts. Both make a gate predictable from the authored spec alone. > > **Three things the build settled:** > > - **A gate is an ADDITIONAL condition, never a replacement.** A phase whose steps are still running > is not advanced by a boss that spawned early, and force-advance refuses a phase held by a step — > that phase is held by the *step*, and skip is its control, one step at a time. A force that swept > past pending steps would be a cancel of half a phase under a button labelled advance. > - **The emit path writes, the tick reads.** A gate waiting on three spawns counts things that > happen *between* two ticks; fifteen seconds later there is nothing left for a poller to see, and > a tally in a process's memory is one a restart silently zeroes. So `observe()` sits beside > `engine.dispatch` in `ctx.events.emit` — a second subscriber rather than a leg of dispatch, > because a rules lookup that throws must not lose the count and a gate write that throws must not > lose the mail. > - **The panel's sentence is rendered on the SERVER.** Everywhere else this feature serves the > client a vocabulary; here it serves text. The labels live in `engagement/conditions.js`, and a > renderer in the browser would be a second implementation of a grammar the server owns. Only the > variables the condition **names** are stored on the gate row — the row is read onto an admin > screen, and a copy of a whole game event's payload would be a second copy of what > `engagement_sends` is careful not to keep. > > **The defect only a real database found, and it was the phase's own statement.** The conditional > increment was written `SET tally = tally + 1, … satisfied_at = CASE WHEN tally + 1 >= needed …`, > which is wrong on MariaDB: **an UPDATE's SET assignments are evaluated left to right, each seeing > the values already assigned**, so the CASE read the incremented tally and a gate needing two > firings closed on the first. Every stub agreed with the intent rather than with the server, exactly > as engagement's cooldown claim did over `foundRows: true`. The increment now comes **last** and the > order of that SET list is load-bearing; `eventRunnerSql.test.js` is what catches a reorder. > > **Two things that had to change underneath.** `setHealth` is now **escalation-only** — health has > always been a high-water mark here, and without a rank a retry after a stall would demote > `stalled` back to `degraded`. And the **catalog route serves triggers**: `/admin/engagement/triggers` > is `adminOnly` while a definition is authored by `admin` *and* `editor`, so pointing the editor at > it would have left an editor typing a trigger id from memory into a field the save path refuses. > > **A leg a stubbing file did not know about, for the third time.** `runs.detail()` gained the gate > read, and `eventsAdmin.test.js` does not stub `eventPhaseGates.db` — so the run-console test hung > ten seconds against the dead-port pool and failed with `ECONNREFUSED`, saying nothing whatever > about the route it was testing. Phase 4's expansion leg did the same to `eventRunner.test.js`, > where it only made the file slow. **When the runner or a model gains a leg, every file that stubs > the layer under it needs the stub** — and the symptom is a ten-second test, whether it then fails > or merely passes. > > **Verified:** `npm test` — **1810 tests, 1746 pass, 63 skipped, 1 fail**, that one still the > pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch's changes > and watching it fail unchanged; `edge` before: 1768/1711/56/1). **+42 is exactly the tests added**, > and the +7 skipped are the new SQL cases skipping without a database. `eventGates.test.js` (14) covers the > renderer against the grammar's own labels and the observer's near-miss branch; 10 in > `eventRunner.test.js`, 5 in `eventRunControls.test.js`, 6 in `eventSpec.test.js`, and **7 in > `eventRunnerSql.test.js` against a real MariaDB** (39/39 with a database, skipped without) — one of > which is the left-to-right defect above. Client: **361 pass**, 7 new. One route added, none moved; > the client builds. > > **The live walk, on the local review stack.** A three-phase `Yew Champion Muster` — an `on` gate > needing two `uo.champ.boss_up` firings `where location contains "Yew"`, then an `after: '10m'` > gate, then an ungated wind-down — authored, published and started as `navadmin`, with firings sent > through the real `ctx.events.emit` seam: > > - **The save-time refusals named the variable**, live: `"regoin" is not a variable of > "uo.champ.boss_up"`, `"gt" cannot be applied to a string`, and `1h30m` refused with the grammar > spelled out. That is the phase's Trap, held on a running server. > - **A near miss was recorded and did not count.** A boss up in *Britain* left the tally at 0 of 2 > and put `did not count (location: "Britain (10, 20, 0)")` on the panel — and **only `location`**, > the one variable the condition names. `spawnName`, `bossName` and `spawnSerial` were in the > payload and never touched the row. > - **Two matching firings advanced the phase**, and the next phase opened its own `after` gate with > a `dueAt` ten minutes out. The log reads: three `condition.evaluated` lines (0 of 2, 1 of 2, 2 of > 2), `phase.advanced`, `phase.completed`, `phase.gate`, `phase.entered`. > - **A second run went `stalled`** with `EVENT_PHASE_STALL_MS=45000` — `degraded` first from a > failing announce, then escalated to `stalled` and logged **once**, which is the escalation-only > guard working live. > - **The panel said exactly what §Observability asked for**: *"Phase muster has not started — > STALLED / waiting on `uo.champ.boss_up` where location contains "Yew" / seen so far 0 of 2 / > since 9:56:58 PM (6 min) / last related event …"*. > - **§N2's split held:** `navmod` pressed *Advance phase* and got 200; the refusals answered 409 > naming what was actually happening — *waiting on step 0 (core.announce), not on its advance > condition* — and a second force said *already past its advance condition*. > > **The walk found three defects, all fixed here:** > > 1. **`validate` refused its own output.** The normalised gate carries `dormant`, and the input > check did not allow it — so a gated definition *saved* and then *failed to publish* over a field > the validator itself wrote. The rule was already on the page for a step's `actionVersion` and > `dormant`; the gate just had to follow it. `validate(validate(x)) === validate(x)` is now a test. > 2. **A forced advance was logged twice** — once by the control with the actor and the reason, then > again by the tick that acted on the satisfied gate, the less informative one last. `phase.advanced` > is now written by whoever made the decision, and the tick skips `forced`. > 3. **A satisfied gate's clock kept running.** `elapsedSeconds` measured to read time, so the panel > said 139s beside a logged `waitedSeconds` of 121. It now stops at `satisfied_at`: live it answers > "how long has this been waiting", afterwards "how long did it wait". **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. *Held: `spec.js` calls `conditions.validate(declaration, where)` at save and re-roots the grammar's own errors at the phase, so an author fixing five clauses at once can tell which phase each belongs to. A gate naming a trigger nothing registers is **dormant** on the rule a step's unregistered action already follows — it saves, and it will not publish.* --- ### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`) > **Complete.** `edge` in `website` and `docs`. Two new tables — `event_action_settings` (the > switchboard) and `event_run_budget` (what a run has spent and the most it may) — plus two columns on > `event_versions`, `verified_at` and `verified_by`. `GET/PUT /admin/events/actions` and > `POST /admin/events/:id/verify` are the last routes this feature was missing bar `cleanup`, and > `events/authorize.js` is the one function §K asked for. > > **The four decisions the org lead settled (2026-09-03), all as recommended:** > > - **The default-off line falls between `inspect` and `change`, not between `notify` and `inspect`.** > §K's sentence read literally would have shipped `core.wait` — which is `risk: 'inspect'` — > disabled, so every published event that waits breaks on a fresh deployment until an admin finds > the switch. An `inspect` action reads state and writes nothing, so a deployment gains no risk by > having it on. §K is amended, and **the same line is the role floor**: `change` and `irreversible` > are the steps only an admin may author. > - **The tightest cap wins.** `event_action_settings.caps` is per action while `event_run_budget` is > one row per dimension, so two actions spending `uo.creatures` have to agree on one number, and the > number a safety limit should settle on is the smaller. It is pinned into the run at creation with > the action it came from, so the console can say whose switch set it. > - **A refusal follows the step's `on_failure`, and takes health to `degraded`.** A cap breach or a > disabled action is not an outage, so it is `refused` rather than `failed` and gets its own log > kind — but it takes the same **disposition** a failure takes, which means a `change` step's > default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes. > Skipping and carrying on would leave a completed run with a hole in it, which nobody reads a log > for. > - **The verify gate is enforced for SCHEDULED starts only.** A version must have passed its dry run > before the scheduler will materialise an occurrence of it unattended; a human pressing *Start now* > is not gated, because that human **is** the review the gate exists to require. > > **Two things derived rather than decided, and both flagged for review.** A dry run **fails** — it > does not merely warn — when a version names a disabled action or over-spends a cap, which is exactly > what the scheduled-start gate is for; and the unattended path **does not re-check the starter's > role**, because demoting an admin at midnight must not silently strand every event they started > (cancel is the control for a run that should stop). > > **Three things the build settled:** > > - **The check is after the claim and before the dispatch, and it can write.** After, because the cap > is held by a conditional `UPDATE` and two ticks that both priced a step before either claimed it > would both spend. Before, because a refusal means the action does not happen at all. And `spend` > is a *parameter* of `mayInvoke` rather than a second function, because checking and then spending > would be two statements with a race between them — the exact race the conditional increment > removes. > - **A retry does not pay the cap twice, and a failure does not get a refund.** The spend happens on > the first attempt only: charging a cap for a flaky socket would exhaust a deployment's allowance > through unreliability rather than through effect. The corollary is deliberate — a step that spent > and then failed for good keeps its spend, because the attempt may have half-run and a refund would > be core asserting that it did not. (A *partial* spend across dimensions IS unwound: a step refused > on its second dimension gives the first one back, because a step that did not run must not have > spent anything.) > - **`NULL` cap is uncapped and still a row; a MISSING row is a refusal.** Keeping the two distinct > is what lets the meter count a dimension nothing bounds while a step spending something its own > run's version never priced still fails closed. > > **The one thing this phase deliberately cannot demonstrate with a module.** `registerEventActions` > is not on the module-facing API yet — that seam is **Phase 7's** — and core's own three actions > declare no `cost`. So the cap machinery ships with no live consumer, exactly as the plan intends > ("ships: a system that cannot be made to do an unbounded amount of anything"). It is proved instead > by unit tests, by a real-MariaDB concurrency test, and by a **rig action added to core for the walk > and reverted before commit**. > > **Two defects the live walk found, and neither could have been caught anywhere else:** > > 1. **The run console route dropped the budget.** `runs.detail()` returned it and the client read it, > but `getRun` builds its response field by field and never listed it — so the meter would have > rendered empty for ever, with the model test and the client both correct in isolation. **A > hand-built response shape is a place a new field silently does not arrive**, and only a request > through the whole stack says so. > 2. **The role refusal did not agree with itself.** `"Spawn creatures" change the world` — the > sentence always used the plural verb over a list that is almost always one long, so the case an > editor actually sees read as a broken sentence rather than as a rule. > > **Verified:** `npm test` — **1921 tests, 1847 pass, 73 skipped, 1 fail**, that one still the > pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch and > watching it fail unchanged; `edge` before: 1810/1746/63/1). **+111 tests**, of which the +10 skipped > are the new SQL cases skipping without a database. `eventAuthorize.test.js` (27) is `mayInvoke`'s > four layers and the cap arithmetic; `eventVerify.test.js` (12) the dry run, including the > whole-plan total; `eventsRoles.test.js` (31) is **the 403 walk**, driving the real router with every > handler replaced so it measures gates rather than handlers; plus 12 in `eventRunner.test.js`, 19 in > `eventsAdmin.test.js`, and **10 in `eventRunnerSql.test.js` against a real MariaDB** (49/49 with a > database, skipped without) — among them the plan's own criterion, two concurrent spends against one > cap. Client: **362 pass**, 1 new; the client builds. Three routes added, **none moved**. > > **The live walk, on the local review stack.** The gate announced itself before anything was > authored: a leftover Phase 4 definition began logging *"scheduled occurrences held: the published > version has never been verified"* on the first tick after boot. > > - **The board arrived with the right posture.** `core.wait` (`inspect`) enabled, `core.announce` and > `core.cue` enabled, the rig's `change` action disabled, and every row marked `configured: false` — > a deployment that has never opened the screen, behaving correctly with no rows at all. > - **The dimension was discovered by pricing the declared example**, live, and the cap editor > offered exactly it. A cap naming a dimension the action does not spend was refused by name. > - **The dry run caught the whole-plan total.** Three steps of 15 under a cap of 30: each legal > alone, `45 of "core.creatures" across all its steps` refused together, before anything was > scheduled. > - **The dry run found a real defect in a leftover definition** — an announce leg `"site"` that no > module registers — and, being a failure, **recorded nothing**, so the schedule stayed held. Fixing > and re-verifying lifted the hold on the same tick and the occurrence materialised. > - **An editor was refused a world-changing step** and allowed an announcing one; an admin saved the > same body. > - **The cap bit at dispatch.** Two 15s spent 30 of 30 and the third came back `refused: asks for 15 > of "core.creatures"; 30 of 30 is already spent this run`, health `degraded`, run `completed` > under `on_failure: skip`. With `pause`, a disabled action stopped the run at step 0 and left the > step after it `pending`. > - **The 403 walk held live** as well as in the harness: `GET` and `PUT /actions` answered 403 to the > editor and the moderator alike, and 200 to the admin. > > **One thing this PR fixes in passing:** the committed OpenAPI spec still carried the pre-Phase-5 > catalog description — the annotation had been edited without a regenerate. `npm run swagger` here > picks it up, which is why the spec diff has two deletions in it that are not this phase's. `event_action_settings` (one row per action an admin has an opinion about — everything that CHANGES THE WORLD disabled by default; the line moved off `notify` when this phase built it, see above) and `event_run_budget` with the conditional increment: ```sql UPDATE event_run_budget SET consumed = consumed + ? WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR 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. (`advance` took that same gate in Phase 5, which is when it first named a state an operator could be in.) *As built, P3 had already put every route gate in place, so what P6 actually added here is the two new routes' own gates and the one gate that could not live in route middleware at all: the per-STEP role floor. The route is `admin, editor` and stays that way — **which of the two you have to be depends on what is in the body**, so it is checked in the model, at save as well as at publish.* **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) > **Complete.** `edge` in `website` and `docs`. MODULE_API **1.10.0**, in both halves. Four names > forwarded on the module-facing `api` — `registerEventActions`, `registerEventBudgets`, > `registerEventLeases`, `registerEventOptionSources` — one new route > (`GET /admin/events/catalog/options/:sourceId`), and one rule made real: a `cost()` naming a > dimension no module declared is refused. > > **The seam is narrower than the four names suggest, and that is the point.** > `registerEventActions` has existed since Phase 1 and has staged core's three actions on every boot > since; what it never had was a way in, because `loader.js` builds its own `api` facade and had no > method that delegated to it. So the registry a module now reaches is one that has been exercised on > every boot for six phases rather than one whose first registrant is a stranger — the argument > `registerCore()` has made since the module system's Phase 3, and this is the phase where it pays. > > **The four decisions the org lead settled (2026-09-03), all as recommended:** > > - **Option sources are their own registration**, `registerEventOptionSources([{ id, label, > resolve }])`, modelled on `registerAudiences`. Not a field on the action that names one: a catalog > has more than one consumer — `uo.options.items` is the allowlist for granting an item and for > taking one back — and two actions declaring it separately would be two allowlists that can > disagree. > - **An undeclared dimension is REFUSED, at save, at the dry run and at dispatch.** Fail closed, so > that §F's *"a module cannot spend a budget it did not declare"* is a rule rather than a sentence. > Not at registration time: `cost` is a function of params, so core could only enforce it against > the declared examples, which is a rule about examples rather than about what runs. > - **A lease is DECLARED here and acquired by nothing.** The ledger that holds one, the deadline that > goes down the wire and the drift answer are Phase 8's. Declaring it now keeps the module contract > one version a module author reads once rather than two. > - **Core registers an option source of its own**, `core.options.legs`. `core.announce`'s `leg` param > was a free-text box whose typo was caught at DISPATCH, mid-run — which is exactly the defect Phase > 6's walk hit, an announce leg `"site"` no module registers. The legs are already a registry with > labels in them, so it costs nothing new, and it means the seam's first exercise is not a module's. > > **Three things the build settled:** > > - **The undeclared refusal gets its own code (`undeclared`) and runs BEFORE any cap arithmetic.** A > dimension nobody declared has no cap to be under and no meter to draw on. The separate code is not > tidiness: an operator told "the cap is spent" goes and raises a cap, and nothing changes, because > the fix is a module's declaration. > - **A budget's `unit` is required and its vocabulary is open.** Required because a bare number on a > cap box is ambiguous in the case that matters — 30 of what? — and open because core never > interprets it. Closing the set would make "kilometres" a MODULE_API bump for a noun core does not > read. > - **A dimension nobody declares is SHOWN on the switchboard, not filtered out.** The action is > refused wherever it is used, so the screen has to be able to say which module is incomplete; > hiding the row would make a broken module look like a cheap one. `registerEventBudgets` supplies > the label and unit, while WHICH dimensions an action spends is still discovered by pricing its > declared examples — a registry cannot answer that, because `cost` is a function of params. > > **The plan's own instruction, followed literally.** *"Prove it with a throwaway module, not with > module-uo."* `eventModuleContract.test.js` (17) writes a real module to a real directory, points > `MODULES_DIR` at it and lets the **real loader** scan, validate, `register()` and commit it — so a > test that staged directly, which would have passed just as happily before this phase, is not what is > being run. It covers all five failure shapes dispatched from a module (rejected promise, throw, > `budgetMs` timeout, non-object, missing `ok`), `retry: false`, `await: 'human'`, `holdFor`, the > whole envelope including the idempotency key, `verify: true` writing nothing, the four id spaces, > `once` on a second call, the un-namespaced refusal, a module that registers nothing at all, every > option-source failure mode, and an action going dormant when its module is uninstalled. > > **The defect only the browser could find, and it is a React one.** > `setSources((s) => { if (s[id]) return s; started = true; … })` — the guard was written inside the > state updater and read on the next line. **`setSources` QUEUES its updater rather than running it**, > so `started` was always `false`, the function always returned early, and the request was **never > made**: the field sat on *"Reading the list…"* for ever. Every server test passed, the route > answered correctly by `curl`, and nothing but opening the page could say so. **State is the wrong > tool for a question that must be answered synchronously, at the call** — it is a `useRef` now. > > **And one thing the docs caught the code doing.** The § API surface table has always named this > route `GET /admin/events/catalog/options/:sourceId`; it was built one segment shallower at > `/admin/events/options/:sourceId`, and the divergence surfaced only when the docs half was written. > Moved to match the spec — which is also the better shape, because a source's values ARE catalog > data, fetched separately so a slow source cannot take the catalog with it. > > **Verified:** `npm test` — **1950 tests, 1876 pass, 73 skipped, 1 fail**, that one still the > pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch and > watching it fail unchanged; `edge` before: 1921/1847/73/1). **+29 tests**: 17 in > `eventModuleContract.test.js`, 11 in `eventActionRegistry.test.js` (the three new shape checks and > the id spaces), and 1 in `eventsRoles.test.js` — the new route joining the 403 walk. `eventsAdmin`, > `eventAuthorize`, `eventVerify` and `eventRunner` all gained the budget declarations their cap tests > now need, and the test dimensions were renamed `x.*` → `test.*` so they carry the registering > owner's prefix, exactly as their action ids already do. Client: **362 pass**; the client builds. > `npm run routes:manifest` and `npm run swagger` — **one route added, none moved.** > `check:modules` and `check:hosts` green. > > **The live walk, on the local review stack**, driven by a throwaway `rig` module in > `website/modules/` — a real module directory, scanned by the real loader, **deleted before commit**. > > - **`coreApi: "^1.10.0"` resolved and `module-uo`'s `"^1.9.0"` still did**, on the same boot. That > is the additive claim, proved rather than asserted. > - **The catalog served all four registrations** with every callable stripped — two budgets with > their labels and units, one lease, three option sources including core's own. > - **The option route answered four ways**: a module's list, a source that throws > (`200`, `ok: false`, *"could not be read"*), a source nobody registers, and core's `core.options.legs` > — which returned `discord` **and** module-uo's `towncrier`, proving the per-request resolve. > - **The switchboard named the dimension.** `Wisps summoned … count` on the cap box, and the > undeclared one rendered disabled with *"No module declares this as a budget, so a step using this > action is refused."* > - **The save refused the undeclared dimension** by name and phase-step path, before anything was > scheduled; the role floor refused an editor the same world-changing step an admin saved. > - **The dry run priced a MODULE's action** — 3 of a cap of 5 — and refused the two-step version at > `8 of "rig.wisps"`. At dispatch the first step spent 3 and the second came back > `refused: asks for 3 of "rig.wisps"; 3 of 5 is already spent this run`, health `degraded`. > - **The dropdown wrote the exact spelling into the params box.** Picking *Britain Hall* put > `"place": "britain-hall"` into the JSON, one request served two steps on the same action, breaking > the JSON greyed that step's picker to *"Fix the params JSON to pick a value"* while the other > stayed live, and pointing the param at the failing source rendered > *"… could not be read — type the value by hand"* in red beside a field that stayed editable. > > **This PR turns the integration kit red**, on purpose. `checkCoreApi.js` asserts equality against the > `main` sha `ci/core-ref.json` pins, so it stays red until the cutover re-pins it (Phase 16). 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`) > **Complete.** `edge` in `website` and `docs`. One table (`event_run_resources`), one core action > (`core.lease`) and one option source (`core.options.leases`), one route > (`POST /admin/events/runs/:runId/cleanup`), one body field (`cancel`'s `{ cleanup }`), and two > members added to MODULE_API **1.10.0 in place** — `reconcile()` on an action and > `ctx.events.reconcile()` on the module context. > > **The version was amended rather than bumped** (org lead, 2026-09-03). A protocol owes a bump once > it has landed on `main`; while it is on `edge` it is amended in place — the rule the Teams > workstream arrived at, applied to a module 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 in it a phase early. > > **The four decisions the org lead settled (2026-09-03), all as recommended:** > > - **A lease is acquired by a new CORE action, `core.lease`.** §F puts the duration bound and the > two-events-one-target conflict check on core's side of the seam, and a lease verb per module > would be both of those re-implemented once per module — advisory everywhere, and wrong in the > first one that forgot. It is `risk: 'change'`, so it is the first core action that is default-off, > admin-only and cap-checked like any module verb. > - **Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key.** A spawn's `ref` > does not exist until the module answers, so there is nothing to write a row about — the row core > writes beforehand is `kind: '@step'`, `ref` = that key. On the answer the reported resources are > inserted `confirmed` and the placeholder is resolved; if the answer never comes it stands, and > cleanup calls `revert({ idempotencyKey, resources: [] })`. **That is why §F's `revert` takes the > key at all.** A lease skips it and reserves its real target instead, which is the same rule in a > stronger form. > - **Cleanup is one sweep over the ledger, not synthetic `event_run_steps` rows.** The step-shaped > version buys the console's per-step retry for free and costs a second retry counter beside > `revert_attempts` — two counters that disagree the first time a step reverts three of its four > resources. The manual retry is the route this plan already promised. > - **`reconcile` is declared here and TRIGGERED BY THE MODULE**, through `ctx.events.reconcile()`. > Core has no concept of the game being up (§F), so it cannot decide when to ask; module-uo already > watches `bootId` to tell a shard restart from a sidecar reconnect, and that is the moment. Core > asks once at its own boot, which is the one reconnect it can see. > > **Four things the build settled:** > > - **The unique key is held by three statuses and released by three**, which corrects §D's *"among > non-reverted rows"* — written before the six statuses had their meanings. Taken literally it makes > `drifted` and `orphaned` hold a target for ever, so one bad night would disable a lease > permanently with no control able to clear it. `drifted` means somebody else has hold of the value > and this run has let go; `orphaned` means it vanished. Neither is a claim, and both stay loud > through `cleanup_status` and the console instead, which is what rule 2 actually asks for. > Recorded as a dated amendment in `EVENTS.md`. > - **MariaDB has no partial index, so the key is a STORED generated column that is NULL once the row > is no longer ours** — and it reads `status` ALONE. `TEAMS.md` §2.5 had to be corrected on this > exact shape: MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column > of a stored generated column (error 1901), and `step_id` must stay SET NULL because a record of > what was changed in the world has to outlive the row that scheduled it. > - **Cleanup runs from ONE place: a fifth leg of the runner's tick**, ordered after advance so a run > that completes in one tick is torn down in the same one. Hooking each terminal path would be four > call sites, three of them inside a request, and none would survive a process that died > mid-teardown. It is also why `cancel` answers at once — the right behaviour for a control pressed > at 2am against a shard that may be the reason. > - **What that leg SELECTS is the phase's real finding, and it took the live walk twice.** See > below: the obvious reading of `cleanup_status` stranded a lease outright, and then made > `EVENT_REVERT_MAX_ATTEMPTS` mean one attempt. > > **Verified:** `npm test` — **2025 tests, 1935 pass, 89 skipped, 1 fail**, that one still the > pre-existing `engagementManifest.test.js` CRLF failure in a file this branch does not touch > (`edge` before: 1950/1876/73/1). **+75 tests**: 14 in the new `eventLedger.test.js`, 20 in the new > `eventCleanup.test.js`, 16 in `eventRunnerSql.test.js` (which skip without a database — see > below), 8 in `eventRunner.test.js`, 9 in `eventRunControls.test.js`, 5 in > `eventModuleContract.test.js`, 2 in `eventActionRegistry.test.js` and 1 in `eventsRoles.test.js`. > Client: **362 pass**; the client builds. `npm run routes:manifest` and `npm run swagger` — **one > route added, none moved.** > > **The unique key was proved against a real MariaDB, because nothing else can prove it.** Whether > multiple NULLs collide in a unique index, whether a STORED generated column is recomputed and > re-indexed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the > server and of nothing else. `eventRunnerSql.test.js` gained 14 tests covering all three, plus the > ledger model's own statements: `DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=… node > --test test/eventRunnerSql.test.js` — **63 pass**. The real `schema.sql` was also applied to a fresh > database *and* to an existing one, because `CREATE TABLE IF NOT EXISTS` next to a generated column > is where a migration silently does nothing. > > **And that file had a latent bug this phase found.** Its model-backed tests required > `eventRunBudget.db`, whose pool `utils/db` builds at require time from `DB_NAME` — and `utils/db`'s > own `dotenv.config()` reads `server/.env`. So on a developer's machine those tests were reaching > that developer's real schema while the fixtures they asserted against were being written into the > throwaway database next door. They passed only because both tables happened to exist in both. > `process.env.DB_NAME = DB` before the require fixes it, and the whole run is disposable again. > > **The stale-stub trap, for the fourth time in this feature.** Phase 4's expansion leg, Phase 5's > gate read, Phase 6's settings read and now the ledger write: a new leg under a model needs a stub in > every file that stubs that layer, and unstubbed it is not a wrong answer — it is a ten-second > `ECONNREFUSED` against the dead port. One missing stub cost `eventsAdmin.test.js`'s run-detail test > ten seconds and said nothing about the route it was testing. > > ### The live walk, and the three defects only it could find > > Driven by a throwaway `rig` module in `website/modules/` — a real module directory scanned by the > real loader, **deleted before commit** — registering one ledgering action and one lease, with its > behaviour driven by a JSON file on disk so a revert could be made to fail and then succeed on the > same boot. 34 assertions, all green at the end. `rig` declared `coreApi: "^1.10.0"` and module-uo's > `"^1.9.0"` still resolved, on the same boot: the additive claim proved rather than asserted. > > **Defect 1 — a lease was never given back at all.** `core.lease` reserves its own ledger row, so it > never went through the ledger's dirty-marking, so a run holding only a lease kept > `cleanup_status = 'not_required'` and the cleanup leg — which selected on `pending` — never looked > at it. Every unit test passed: they call the sweep directly, and a test that calls the sweep never > asks what would have SELECTED the run. > > **Defect 2 — `EVENT_REVERT_MAX_ATTEMPTS` meant one attempt, not three.** The first failing sweep > moved the run to `incomplete`, which took it out of the leg's own scan for ever. Visible only as > `revert_attempts` sitting at 1 through half a minute of live ticks; the test that covered the bound > asserted `<= 3` and was satisfied by 1. **A bound has two halves — it retried, and it stopped — and > a test that only asserts the ceiling passes against a floor.** Both halves are asserted now. > > **Defect 3 — the first fix for defect 2 made the console lie.** Spending every row's > `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and > the run page then rendered *"3 attempts"* beside resources nothing had ever tried — which reads as > *"core tried three times and could not"*. Found by opening the page, exactly like Phase 7's React > defect. **A counter that means two things is a counter a screen cannot render.** > > **What the walk proved, beyond the defects:** three wisps recorded and given back with the run > reaching `complete`; a failing revert leaving the run `completed` + `incomplete` with the reason on > each row; the cleanup route rescuing it and a moderator being refused it; the route refusing a run > still in flight and one that recorded nothing; a lease taken, its baseline and applied value in the > payload, and a **second run refused it by name**; a GM's mid-event edit producing `drifted` with the > world left alone, and a later run still able to lease that target; and `cancel { cleanup: false }` > refused to a moderator, allowed to an admin, and logged. > > **The two that needed the process to die**, which is the plan's own verify line. With the module's > `perform()` hanging: the placeholder existed while the dispatch was in flight, **nothing was named** > (`unresolvedResources: 1`, an empty list — rule 1 visible in the wild), and after `taskkill` and a > restart the reclaim re-dispatched the SAME idempotency key, the retry re-used its own placeholder > rather than writing a second, and everything was given back. Then, with the module reporting one of > two resources as no longer in force, core's boot-time `reconcileAll()` marked the other `orphaned` > — never `reverted` — and logged it. > > **The console, in all three states.** A clean run: neutral border, *"Everything this run created or > borrowed has been given back"*, green rows, no button. An unresolved run: amber border, *"3 of these > are still unresolved"*, *"still out there"*, and **Try cleanup again** — which was clicked, answered > `200`, and flipped the panel green in place. A drifted lease: *"someone else moved it"*, its > deadline, its genuine attempt count, and the sentence naming the value that is there now. > > **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 P11a. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the declaration says why. > **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option > sources, `MODULE_API` unchanged at 1.10.0 — the module registers against the contract Phase 7 > opened rather than widening it. > > **The trap above is real and its stated fix does not work.** `on_failure: 'skip'` is already the > default for `risk: 'notify'`, and `on_failure` is what happens AFTER `EVENT_STEP_MAX_ATTEMPTS` > retries — there is no per-action lever meaning "do not retry me". The lever a module has is the > failure envelope, so `uo.broadcast` answers `retry: false` to everything. **And that alone is not > enough**: `dispatch.classify()` answers `retry` for a budget timeout unconditionally without asking > the action, and the default `budgetMs` (10s) is SHORTER than `uoLinkClient`'s own timeout (12s), so > core's deadline fired first on every slow shard and the refusal was unreachable. All three actions > declare `budgetMs: 15000`. That is the phase's most portable finding and it is now a contract rule > in `MODULE_API.md` §2.4 rather than a fact about one module. > > **`reconcile()` needed no protocol work.** There is no "list the crier lines" or "list the news > articles" on the wire, and adding one would be protocol work for a question the module can already > answer: both live in shard memory, so a restart is definitionally the loss of both. `perform()` > stamps the shard's `bootId` into the resource payload and `reconcile()` reports in force exactly > the rows whose stamp still matches — correct for the module's own trigger AND for core's boot > sweep, where the shard may not have restarted at all and answering "all gone" would abandon live > rows. A row with no stamp is reported IN FORCE: "I do not know" must never read as "it is gone". > > **Four decisions (org lead, 2026-09-04), all as recommended.** `uo.towncrier.post` ships ALONGSIDE > the existing `towncrier` announce leg rather than replacing it — the leg is post-shaped, one-shot > and non-reversible, the action is run-scoped, takes lines and a duration directly, and is > `reversible: 'ledger'`; a budget dimension `uo.broadcasts` bounds the broadcast alone, because the > keyed verbs replace under their own id and have no runaway to bound; the option sources ship here > rather than with their Phase 12 consumers; and an event write records `event:` as its actor, > because no staff member pressed a button and attributing it to one would be a false line in the > game's own audit trail. > > **The walk: a real two-phase event, against ServUO + the sidecar + the website.** All three verbs > reached the shard; the shard's own audit read `[Bridge][admin] web:event:3692 broadcast`, which is > the actor decision visible where it was meant to be. The dry run refused the event for asking 2 of > `uo.broadcasts` against a cap of 1 — before publishing — and passed at 2. The option sources > resolved from the real atlas: 387 regions, 558 landmarks, 800 creatures, all well inside the 2000 > bound. Teardown reverted the crier line and the news article. Then the shard was restarted mid-run > and core logged `orphaned on reconcile {asked: 2, inForce: 0, orphaned: 2}` **31 milliseconds after > the bootId changed** — the phase's headline property, proved rather than asserted. > > **Three defects it found, two of them data loss, none visible to any unit test.** > > 1. **A WS reconnect would have orphaned every live resource.** The backfill replays the last > several `server.hello` frames in order — this rig saw three, each with a different `bootId` — so > every replayed frame reads as a restart, and the intermediate ones compare a resource stamped > with the CURRENT boot against a boot that ended hours ago. The row is then `orphaned`: a live > crier line core will never take down again, lost to nothing worse than the website reconnecting. > Gated on `!fromBackfill`, which is the rule the engagement fan-out and the SSE broadcast beside > it already state; the website-was-down case is still covered by core's own boot sweep. > 2. **The shard explains its refusals and the run log dropped the explanation.** A 403 body reads > `{"reason":"admin write plane disabled"}`, `legError` looks for `data.message`, and the console > said "sidecar responded 403". A staff member clicking a button knows what they switched off; an > event that ran at four in the morning leaves the run log as the only place anyone will learn why. > 3. **The "not retried" clause explained the wrong thing.** A 403 will not succeed on any attempt, > so "not retried: a repeat would announce twice" points an operator at a policy decision instead > of at the switch they have to flip. The clause is now added only where a retry was genuinely > given up, and 403/404 join the statuses the keyed verbs treat as terminal. > > **And one it found in Phase 8's shipped code, left for its own change.** `UNRESOLVED` in > `eventRunResources.db.js` includes `orphaned`, so the cleanup sweep selects an orphaned row, calls > `revert()` on it and records it `reverted`. That contradicts §L's own rule — *"a resource the module > no longer has becomes `orphaned`, never `reverted`"* — and the console then says core put back two > things that had vanished. It also spends a sidecar round trip per orphaned row on a shard that has > just restarted. Not fixed here: it is core's file and a Phase 9 PR is the wrong place for it. > > **`ci/core-ref.json` moves to a website `edge` sha** for the length of this workstream (org lead). > `registerEventActions` exists only from 1.10.0, so under the old `main` pin `register()` throws and > the module does not load at all — the frozen-manifest job would have been red by construction for > eight phases and proved nothing. Phase 16's cutover re-pins it to `main`. --- ### 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. > **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. > **Done in 14a**, as `eventUrl`, carrying `?run=` — and the six went to version 2. > - **`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` + `docs`) **§N1 answered 2026-09-01 — no longer gated.** A protocol bump, and the shape TEAMS Phase 1 already walked. **Split into 11a and 11b (org lead, 2026-09-04).** Three independent mechanisms across four repos, each with a different thing to prove on a live ServUO, is one review and one walk too many. They land as **one protocol version**: 11a bumps to 6, and 11b amends 6 **in place** on `edge` — the same rule the org lead set for `MODULE_API_VERSION` on 2026-09-03, and it applies because 6 will not reach `main` until the events cutover. The bundle CI therefore never sees two numbers. **`installer` is NOT in this phase, and the line above that said so was wrong.** `PLAN.md` §7.4 made sure of it deliberately: no protocol version is hardcoded anywhere in the installer. `bundle.rs` reads the number out of the bundle document, and `bundle.yml`'s Gate 1 reads both halves out of the released artefacts and refuses a mismatch — version-agnostic, so it needed no change for 5 either. And `link`/`servuo-plugins` are on `edge`, so nothing is released or bundled until the cutover. #### Phase 11a — the idempotency key, and `champ.boss.killed` - **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a repeat with the original result; `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 key carried; the new kind stored and served. - **`module-uo`:** the key sent on every event-driven write, `uo.broadcast` made retryable, and the new kind mapped for visibility and declared as a trigger. - **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`. **Ships:** the game side can refuse a duplicate command. A safety property, not a feature — and the precondition every world verb in Phase 12 is waiting on. **Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new code is live; a deliberate duplicate command, checked against the shard's own audit trail rather than against the reply. > **Built.** `PROTOCOL_VERSION` and `overlay.toml` both **6**, in the same pair of PRs. The whole > sidecar change is one constant and one status mapping; the whole guarantee lives on the shard, > which is the only place it can, because the shard is where the world write happens. > > **Seven decisions (org lead, 2026-09-04), all as recommended.** The 11a/11b split; participation > keyed by character serial; the ledger persisted in the world save; leases as machinery plus one > proven key; reserve-on-receipt with `bridge.busy` for an in-flight repeat; a TTL-plus-cap with a > loud eviction; and top damagers at a `staff` ceiling. The last three are 11a's. > > **What the phase bought immediately:** `uo.broadcast` stopped being un-retryable. Phase 9 shipped it > answering `retry: false` to *everything* including a 503 from a restarting shard, with a comment > naming the line that would change. This is that line. It now defers to `sidecarFailure` — the same > helper its two siblings already used — so the hand-rolled variant that forced every outcome > terminal is gone rather than merely re-tuned. > > **One verb was less idempotent than its own `id` made it look.** Both keyed verbs post under a > run-scoped id and a repeat REPLACES, which is why Phase 9 called them safe to retry. But > `news.add` with `announce: true` makes the criers proclaim the article's title on every post, so a > retry replaced the article silently and proclaimed it **again**. The key stops the second > proclamation. Read in the plugin, not inferred: `BridgeNews` removes the old entry, inserts the > new one, and then announces. > > **The live walk found one defect, and the naive check confirms it.** A champion killed in the > middle of Britain emitted a frame with **no region**. An active `ChampionSpawn` registers a > `ChampionSpawnRegion` over its spawn area with a **null name** and the town region as its parent, > so the most specific region containing a champion boss is the one region on the map guaranteed to > be nameless — and `Mobile.Region` hides that by falling back to the map's unnamed default rather > than to null. Region registration is deferred, too, so a lookup at spawn time answers `"Britain"` > and one at the kill does not: the probe printed `"Britain"` on every run, including the ones whose > frame carried nothing. Fixed by walking outward to the nearest NAMED ancestor, which is the general > answer — house regions, dungeon sub-regions and guarded-zone overlays are all anonymous children of > somewhere a player would name. > > **Two findings that are rules rather than facts about events:** > > - **A trigger is not a stream, and in this module they are disjoint sets.** Push delivery is keyed > on the subscription id, which core's catalog grants only to registered STREAMS. `module-uo`'s > stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids (`uo.champ.started`, …) share > no member, so **no** engagement rule in this module can push — the tickle resolves to zero > endpoints while the send log records success. That is the Phase 10 defect, pre-existing here in > twenty rules. This phase does not fix them; it declines to add a twenty-first, so its rule ships > `['email','inapp']` with a comment saying why. **Flagged for the org lead as its own change.** > - **A new rule needs a new seed GROUP, never an appended one.** `triggers-v1` is stamped once under > a settings guard, so a twenty-seventh entry would reach fresh installs and nothing else. > `champ-boss-killed-v1` is its own group — the same remedy core applied in Phase 10, and the third > application of Engagement Phase 11's seed-key finding. > > **What the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat cannot > arrive while the original runs — the Core thread takes one inbound line at a time — so the state is > unreachable on a live shard until a handler defers. Implemented, with `Hold`/`Complete` as the door, > and unit-tested at the sidecar's mapping on all three responders. **11b's leases are the first > thing that can produce it**, and proving it belongs in that walk. > > See [`../link/v6.md`](../link/v6.md) for the spec of record. #### Phase 11b — lease deadlines and the participation ledger - **Plugin:** a lease deadline timer that restores baseline **without being asked**, with compare-and-set restore reporting `drifted`; a run-scoped participation ledger. - **Leases land as machinery plus ONE proven key** (org lead): the generic registry, the deadline timer, CAS restore and `lease.list` reconcile, proved end to end against one verified live-read `Config.Get` key. The curated allowlist and its boot-time self-check stay in Phase 12, where the rest of the lease surface is. - **The participation ledger counts presence in a declared area plus kill credit inside it, keyed by character serial** — matching `module-uo`'s existing Teams `memberKey`, so one module speaks one member vocabulary. The plugin computes the score; core stores an opaque number it never interprets. - **The ledger is persisted in the world save**, which makes it the Bridge's first persisted state ever. A run spans hours and a restart mid-event is realistic; an in-memory tally would regress the score after one, and the only ways to paper over that are a high-water rule in core (which must stay game-agnostic) or a per-run offset in the module. - **Protocol 6 is amended in place**, not bumped to 7. See the split note above. **Ships:** the game side can put the world back on its own, and can say who took part. **Verify:** a lease whose website is killed before it expires, proving baseline returns anyway; a deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a tally that survives a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes reachable. > **Built.** All four verifications passed on a real ServUO with the release sidecar. Protocol 6 > amended in place; `MODULE_API_VERSION` amended in place at 1.10.0. See > [`../link/v6.md`](../link/v6.md) §§7–10. > > **It is FIVE repos, not four, and the plan was wrong about this in the opposite direction from > 11a.** A lease's ledger row had no reconcile path anywhere, and nothing failed to say so: > `cleanup.js` resolves a resource to the action of the step that made it, and for a lease that > action is `core.lease` — a CORE action, on a path a module cannot register anything on. So every > `override` row came back `unanswered` for the life of the run, and a lease the shard had quietly > dropped stayed in the ledger as live until teardown went hunting a baseline nobody was holding. > `website` joins the phase: `core.lease` gains a `reconcile()` and `registerEventLeases` gains an > optional **`inForce()`**. Deliberately not `read()` plus a comparison — a changed value is DRIFT, > which teardown must report so the row lands `drifted`, and inferring absence from it would orphan > the row first and tell the operator the lease vanished rather than that somebody moved it. > > **Ten decisions (org lead, 2026-09-04), all as recommended.** The five-repo correction and > `inForce()`; `PlayerCaps.SkillCap` as the one proven key; a scaffolding write verb to make > `drifted` reachable at all; leases memory-only, so a restart is a free restore; a separate > `Bridge.EventsEnabled` gate rather than `AdminWriteEnabled`; map + point + radius for the area; > presence-plus-weighted-kills for the score; the shard-side bounds and grace window; chunking > `participation.snapshot` as the thing that defers; and no `MODULE_API` bump. > > **The catalog is far shorter than §D expected, and the measurement is the finding.** §D frames the > 258 `Config.Get` call sites as splitting into two patterns. Measured: of the **158** non-Bridge > sites in `Scripts/`, roughly **eight** are read live. The allowlist is not a curated subset of a > large pool — it is nearly the whole of what exists. And **`Config.Set` has exactly one caller in > the entire tree** (`Server/ScriptCompiler.cs`), so no in-game command, gump or console verb writes > a config key: on a stock shard a GM cannot drift a *configuration* lease even deliberately, which > is why proving `drifted` needed a `configset` verb in the rig driver. > > **The walk found a defect in 11a's shipped code, which is the argument for the ordering.** > `bridge.busy` came back **200**, not 425, the first time anything produced it: > `BridgeIdempotency.Busy` built its frame with `Begin("bridge.busy")` and then appended a diagnostic > `.Str("kind", prior.Kind)`, so the object carried **two `kind` fields** and every JSON parser takes > the last. The sidecar read `participation.snapshot`, matched nothing, and answered a 200 whose body > said nothing had happened — the worst of the three possible answers, because a retry loop reads it > as success. Unreachable in 11a by construction, and the first deferring handler produced it on its > first collision. Renamed `busyKind`. > > **One resource in `module-uo` must NOT reconcile by boot stamp, and it is this one.** Every other > resource wave 1 ships is stamped with the shard boot that created it, because a crier line and a > news article live in shard memory and a restart is definitionally the loss of both. The > participation ledger is written into the world save *specifically* so it survives a restart, so > the stamp would orphan the one resource the phase went to the trouble of persisting. It asks > instead, and only a 404 takes a row out. > > **What the rig could not drive: presence.** The sweep credits online players (`NetState != null`), > which is the correct test and not one a probe should loosen — a character parked in Britain and > logged out for eight hours did not attend anything. There is no way to produce a NetState short of > writing a client. Kill credit needs none, so the credit path, the accrual, the persistence, the > chunking and the replay were all driven; the one unexercised line is the presence accrual itself, > and it is named rather than assumed. > > **Two rig traps, both of which faked a defect.** `Core.Kill` does **not** save the world, so the > first restart test reloaded an empty `Participation.bin` and looked exactly like a persistence bug > — `save` then `shutdown` is the sequence. And a probe that means to produce two damagers must not > kill with the first blow: 40 damage on a Mongbat killed it where it stood, the second damager never > landed a hit, and the ledger correctly credited one player while reading as a plugin that credits > only the killer. --- ### 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. **Split into 12a and 12b (org lead, 2026-09-07)**, on the line §G already draws: what an event **owns** and what it **borrows**. Ten verbs, four repos and a protocol bump is one review and one walk too many, and the two halves prove different things — 12a proves a run can put things in the world and get all of them back, 12b proves it can change something it did not create and give that back unchanged. They land as **one protocol version**: 12a bumps to **7**, 12b amends 7 **in place** on `edge`. That is the 11a/11b shape, and it carries the same hazard §7 of `v6.md` states — an overlay and a sidecar both declaring 7 are interchangeable only within one side of the 12b merge — tolerable for the same single reason and no other: nothing is released from `edge`, so the bundle CI never sees two meanings of 7. `installer` is not in this phase, for the reason Phase 11 records: no protocol version is hardcoded anywhere in it. #### Phase 12a — what an event OWNS **Built and merged to `edge`** — `servuo-plugins#23`, `link#38`, `Module-uo#31`, `docs#221`. Five verbs that put something in the world, ledger its serial, and delete it at teardown. | Verb | Cap dimension | Author's action | | --- | --- | --- | | Named, hued creatures from the atlas's ~800 constructible types | `uo.creatures` | `uo.creature.spawn` | | "Simple" boss variants, as event-owned creature templates | `uo.bosses` | `uo.boss.spawn` | | Oracle NPCs with scripted dialogue | `uo.npcs` | `uo.npc.place` | | Temporary gates | `uo.gate.minutes` | `uo.gate.open` | | Temporary decoration | `uo.decor` | `uo.decor.place` | **One command family, five author verbs** (org lead, 2026-09-07). Every row above ends in "an object exists and this run owns it", so the wire carries `world.spawn` / `world.despawn` / `world.owned` with a `what` discriminator, and the per-verb differences — a boss's stat multipliers, an oracle's lines, a gate's target and deadline — are **fields rather than kinds**. One ledger shape, one teardown path, one reconcile, instead of five near-identical ones in three repos. An *author* still sees five verbs, because five is what they are: the discriminator is a wire detail, and a dropdown of `what` would be a worse form than five clearly-named steps. **The caps are the module's, never core's** (org lead, 2026-09-07). `uo.creatures` and the rest are declared by `module-uo` through `registerEventBudgets`, exactly as `uo.broadcasts` already is; core meters whatever dimensions a module declares and holds no UO knowledge — which is the whole of what §F means by game-agnostic. The shard additionally carries its own `Bridge.EventsMax*` ceilings and **refuses rather than clamps**, on `Bridge.LeaseMaxDurationSec`'s argument from 11b unchanged: the shard's bound exists for the case where the website is wrong, and being loud about it is its value. **Ownership is persisted, and that is forced rather than chosen.** A spawned creature lives in the world save, so unlike a crier line it *survives* a restart — which already means `reconcileByBootId` is wrong here, for the reason it was wrong for the participation ledger, and reconcile has to ask. But the record of *which run owns which serial* has nowhere else to live. Held in memory it is lost in the restart the creatures survive, orphaning them. Held only in the website's ledger it is not held on the shard at all, so `world.despawn` would delete whatever serial it was handed — and "an event never touches a creature it did not create" is the sentence the boss verb is built on. So the Bridge gains its **second** persisted file, beside `Participation.bin`. **The oracle is our own, and ServUO's own dialogue engine is the reason for both halves of that.** `XmlSpawner2.XmlDialog` already implements exactly the vocabulary this verb wants — `Text` plus a comma-separated `Keywords` list, an entry with no keywords being the greeting, a proximity range, a per-player conversation lock — which is evidence the shape is right rather than invented. It is also the reason not to build **on** it: `SpeechEntry` carries an `Action` string, XmlSpawner's command-scripting language, and routing authored dialogue through XmlDialog would leave an arbitrary-command field one step from an event author. That is the `[set` §G excludes, arriving by the back door, in a subsystem we do not own and a shard can switch off. `Mobile.OnMovement` (delivered to **every** mobile in range — the `HandlesOnMovement` filter applies only to Items, `Server/Mobile.cs:3369` against `:3375`) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are the whole of what the verb needs. **Decoration comes from the shard's own decoration files.** `Data/Decoration/**/*.cfg` names every item type the shard already uses as decoration, with its item id (`LargeCrate 0x0E3C`). The atlas build indexes them, so the dropdown is derived from the operator's own tree and resolves with the shard down — and the list is "decoration" by the shard's own definition rather than by our taste. The plugin validates the type independently, because it cannot trust the website. **Ships:** an event can populate a venue — creatures, a boss, an oracle, a gate to reach it and decoration around it — every piece cap-bounded, ledgered by serial, and gone at teardown. **Verify:** the whole rig. A run that spawns one of each of the five; a restart mid-run proving the ownership registry survives it and reconcile still answers; a creature killed by a player proving "gone" is an ordinary teardown outcome and not a failure; and a despawn **refused** for a serial the run does not own. #### Phase 12b — what it BORROWS, and the one-shots | Verb | Owned or borrowed | Cap dimension | | --- | --- | --- | | A property on an existing object | **borrowed** — a lease | — | | A live config value | **borrowed** — a lease | — | | Seasonal-event toggle | borrowed | — | | World save | neither — a one-shot | — | | Item grant | owned, `reversible: 'none'` | `uo.rewards` | **The config lease catalog does not grow, and the promise of "the rest of the allowlist" was written before anyone counted.** Measured on ServUO 57.4: 156 non-Bridge `Config.Get` call sites in `Scripts/`; 82 sit outside a field declaration, but all but four of *those* are inside a `Configure()` or a static constructor, and so are cached at boot exactly as the field initialisers are. The genuinely live, event-useful reads are `PlayerCaps.SkillCap` — which 11b already shipped — and `Vendors.BribeDecayMinTime`/`MaxTime`, which is vendor bribe decay and which no event would plausibly lease. (`Staff.*` in `GMbody.cs` is live, and is staff-body cosmetics.) So **12b's lease work is object-property leases**, and the config half of the catalog is finished at one key. **The boot-time self-check ships anyway** (org lead, 2026-09-07). On a stock shard it guards a one-key catalog, which is not why it exists: it exists for the operator whose *own* scripts read config live, and it is the mechanism that keeps a capability that lies out of the advertised catalog. A key that sets, reads back and restores at boot stays; one that does not drops itself and says so. §D and §G of `EVENTS.md` carry the measurement, so nobody re-plans against "258 call sites, two patterns". **An object-property lease names its target by an allowlist of (type, property) pairs, addressed by serial** (org lead, 2026-09-07). The plugin ships the catalog — `Spawner.Amount` / `MinDelay` / `MaxDelay` to start — and refuses any serial whose type is not in it. That mirrors the config catalog exactly, and keeps §G's "a curated allowlist the plugin ships" true of both halves of the lease plane; the alternative, a property name and a serial taken on trust, is `[set` with extra steps. It also finally puts compare-and-set in front of a real hand: a spawner is trivially drifted with `[props`, which is the test 11b could run only with scaffolding, because `Config.Set` has one caller in the whole tree. **§G describes the seasonal toggle wrongly, and 12b corrects it.** It calls `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum". `EventStatus` has **three** values — `Inactive`, `Active`, `Seasonal` — and it is `EventType` that has nine entries. The verb is a three-state toggle over nine named events, which is a different form to author and a different one to cap. **Built** — `website#—`, `servuo-plugins#—`, `link#—`, `Module-uo#—`, `docs#—`, all onto `edge`. Spec of record: [`../link/v7.md`](../link/v7.md) §11–§14. **It is FIVE repos, and `website` is the one 12a did not need** (org lead, 2026-09-07: *"you can do the 5 repos and no API bump since it is still on edge"*). A targeted lease is a shape `core.lease` did not have. Every lease before it named a single value, so the lease id WAS the target and none of the four callables took one; `Spawner.MaxCount` is one capability over thousands of spawners, and a reservation on the id alone would let one run turning up one spawner refuse every other run every other spawner. So the declaration gains a `target`, the callables gain it, and the ledger ref becomes `#` — which puts the two-events-one-target refusal at the granularity the world actually has, and leaves it coming from the same unique index it always did. **Extending core rather than giving the module a lease verb of its own is what §F already decided** (Phase 8, *"the verb is core's"*): a lease verb per module would re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and wrong in the first one that forgot. Half of that objection no longer holds — the target check comes free from the index whichever verb reserves the row — and the other half still does. **`MODULE_API_VERSION` stays 1.10.0, amended in place**, the shape every phase since P10 has used while this workstream sits on `edge`. Two more contract members came with it, both forced by this phase rather than chosen: **`values` on a `string` lease** (the seasonal status is a three-value enum and nothing bounded `string`, so its only check was the game side's — a refusal arriving unattended, mid-run) and **searchable option sources** (see below). **The spawner dropdown was the phase's one genuine blocker, and the answer was a core change** (org lead, 2026-09-07). `resolveOptionSource(id)` took no argument and every source answered a flat list bounded at 2,000. This tree has **6,707 spawn points**, so a flat list would have dropped two thirds of the world and said nothing about which two thirds — the exact failure 12a named for decoration, arriving for real. `resolve({ q })` is additive: every source is passed a term, none is required to read one, and a `searchable` flag says which do. The atlas also keeps `` again (parser version 4), because it is the only name for one particular spawner that exists off the shard — a serial is assigned when the world is built, so a lease addressable only by serial could have had no dropdown at all. **Three plan assumptions the tree disproved.** - **`Spawner.Amount` does not exist.** The property is `MaxCount`, and `MinDelay`/`MaxDelay` are `TimeSpan` rather than numbers, so the wire carries seconds. `Spawner` and `XmlSpawner` share all four names, which is why one catalog covers both — and why a catalog naming only one of them would have worked until the day it did not. - **A property lease's hold must be PERSISTED**, and the config lease's must not. 11b's fail-safe is *"a lease that never reaches disk means a restart is a free restore"*, which depends on the leased value being memory-only too. A spawner is in the world save and a seasonal status is in `Saves/Misc/SeasonalEvents.bin`, so a restart preserves the CHANGE and destroys only the timer that would undo it. The Bridge gains its **third** save file, written by the same `EventSink.WorldSave` as what it describes; a deadline that passed while the shard was down fires at once. - **The seasonal toggle is not "small and safe", and one of the nine lies.** `OnStatusChange()` generates or removes world content for six of them — safe, since ServUO does it to itself from a staff gump, but not small. And `TreasuresOfTokuno` is excluded: `IsActive()` reads its own `DropEra` rather than `Status`, so a lease on it applies cleanly, reads back, restores cleanly and does nothing at all. That is §N10's "capability that lies" in its purest form and the one instance no runtime probe can catch, so it is excluded by name at both ends. **Who receives a grant is answered on the SHARD.** The website has the list in `event_run_participants`, but a module cannot read core's tables, so the alternative was a new core surface handing participants to a module's `perform()`. It is not needed: protocol 6 part b's participation ledger already holds them, keyed by the same character serials core stores as `member_key`. A run with no ledger open is a 404; a run whose ledger is open and empty is a 200 with `granted: 0`, because an event nobody attended still happened. And **the grant is retryable** — §G called it un-retryable before protocol 6 existed, and an idempotency key means a repeat is answered by the original reply. **One defect in already-merged code, and it would have broken everything.** The website's protocol pin never left **5**: `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` and an exact mismatch is a `409`, so on any real deployment every sidecar call would have been refused from Phase 11a onward. It survived two phases because both live walks set the column by hand while standing the rig up. 12b carries all three declaration sites to 7. The test that guards them asserts they agree WITH EACH OTHER, which is a real check they once failed — but all three being equally stale passes it, and nothing in `module-uo` can anchor it to the wire. **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`) > **Complete.** `edge` in `website` and `docs`. The two raw JSON boxes Phase 3 shipped as explicit > placeholders are forms; the meter, the searchable dropdown and rehearsal are reachable. **One > route added** — `POST /admin/events/price` — and `MODULE_API_VERSION` is untouched: every schema > the form renders was already in the catalog. > > **Four decisions the org lead settled (2026-09-07), all as recommended:** > > - **The meter gets a route of its own rather than riding the dry run.** A module's `cost()` is a > server function of params, so nothing can be totted up in the browser — and the dry run is the > wrong call to make on a debounce twice over: it dispatches every step through the module (and > through it to a sidecar and a game tick), and a pass against a published version is RECORDED, > which is the stamp §K's unattended-start gate reads. `POST /admin/events/price` dispatches > nothing and records nothing, takes the spec in the body because the plan being priced is unsaved > between keystrokes, and is `admin, editor` for `verify`'s reason. > - **The advance-condition builder is in scope**, though the phase sentence did not name it. P13 is > the last authoring phase, so *"a later phase"* was this one or none. It is the engagement builder > — the same `conditionRowsFrom`/`conditionsFromRows`, not a second one shaped like it — because > the grammar behind a phase gate IS the engagement grammar, validated on the server by > `engagement/conditions.js` and rendered into the diagnosis panel's sentence by the same labels. > - **Start now opens a dialog carrying `rehearsal`, `scope` and `params`.** All three have been on > the route since Phase 10 and this screen posted `{}`. > - **The searchable option source gets its consumer**, the JSON box stays as a per-step escape > hatch, and the timeline gains the per-phase cap draw §I asks it for. > > **Two defects this phase closes in already-merged code, and neither was visible from a test:** > > - **12b's searchable sources had no consumer.** The server half shipped — `q` on > `/catalog/options/:sourceId`, `searchable` on the answer — and the only UI that reads a source > never sent a term. The spawner list is 6,707 entries against `MAX_OPTIONS`' 2,000, so the one > screen that picks a spawner was picking from a truncation of two thirds of the world, with > nothing on it saying so. > - **An event whose concurrency key names a `{placeholder}` could not be started correctly by > hand.** The key is rendered from the run's own params; a start posting `{}` rendered the same key > every time, so the second manual run was refused as an overlap with the first — the failure > looking exactly like the safety feature working. > > **What the build settled.** > > - **A form gives way to the JSON box on the CONDITION BUILDER's rule, not on a new one.** A value > the editor cannot round-trip is shown rather than silently rewritten: dropping a param the action > does not declare and flattening `A and (B or C)` into `A and B and C` are the same mistake, a > save that looks clean and means something else. Three things force it — a dormant action, an > undeclared param, a value no single control can hold — and the screen names which. > - **A step core cannot price is reported, never counted as free.** The three ways that happens all > make the total an under-count, and an author trusting a number smaller than what will happen is > worse off than one with no number. An undeclared dimension is the one that is still *counted*: > the action really will try to spend it — the step is refused at dispatch for exactly that — so > the amount is true and it is the enforcement that is missing. > - **A boolean param is a three-value select, not a checkbox.** A checkbox cannot say *"not set"*, > and for an optional boolean that is a real third state: the action's own default. A checkbox > would have posted `false` for every param nobody touched. > - **An empty field removes the key rather than posting `""`.** `checkParams` reads `undefined`, > `null` and `''` alike, so a required param left blank comes back as *"is required"* — the error > the author needs — instead of a type complaint about an empty string. > - **A half-typed number is kept as typed.** `coerceLiteral` is borrowed from the engagement builder > unchanged, and its rule matters here for the same reason: turning `-` into `NaN` mid-keystroke > either posts a value nobody wrote or makes a negative impossible to enter. > - **The meter debounces AND counts generations.** Requests 400ms apart do not necessarily answer in > that order, and an older answer landing last leaves the meter describing a plan that has already > changed — stale in the one direction that matters, with nothing on screen to say so. A failure > leaves the last answer standing, dimmed, rather than blanking it: the plan is still saveable, > dry-runnable and publishable without a meter. > > **Verify, as run.** Server `npm test` (the new `eventPrice.test.js`, 13 tests, plus the 403 walk's > new row); client `npm test` — **380 pass, 0 fail**, 18 of them new; the client builds. > `routes:manifest` and `swagger` regenerated — one route added, none moved. 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 — SPLIT into 14a and 14b Split by the org lead on 2026-09-07, on the repo line: the app cannot be walked against a page that does not exist yet, so 14b's emulator walk is only meaningful once 14a has merged. The same argument 12a/12b was split on. #### Phase 14a — the public and player surface (`website` + `docs`) — **built** `GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`, `/player/events/history`, and an `events` capability string. `website#196`, `docs#224`. Four org-lead decisions, all taken up front: split the phase; add a **`listed`** flag rather than letting `state` mean both schedulable and announced; put the capability string in the **`version` block** rather than publishing core as a pseudo-module; and **drop "venue"** from this document rather than adding the field §I had described since the first revision and nothing had ever built. `listed` is the one that changes the shape of the work. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in P13's editor, and three SQL predicates — never a filter applied after a read, which works exactly as well until the first caller that forgets. The six public `event.` triggers gained **`eventUrl`** here, which is the version bump this plan promised (1 → 2). It carries `?run=`, because the page lives at the definition's slug and every one of those triggers is about one occurrence. `notify.event-started` gained the button to go with it, at `seedVersion` 2. **Ships:** the public calendar, event pages with their storyline and results, and participation history. **Verify:** both suites; a browser walk of the calendar, an event page, an arc and the history. **The walk found three defects and the suites found none of them**, which is the argument for the walk in one line. Two were one mistake in two files — a split reading a status where it should read a clock — and the third was a screen the reviewing admin could not open at all, because `RequirePlayer` guards `/account` and the route behind it is role-agnostic. Engagement Phase 7 had already solved that one; the fix is its `notificationPaths.js` mapping gaining a third entry. #### Phase 14b — the app (`android-app` + `docs`) — **built** `android-app#44`, `docs#225`. **TWO repos**, and `MODULE_API_VERSION` is untouched. Recorded as **M13** in `docs/android/PLAN.md`, which gained the milestone before the code was written. The app's events screens — calendar, event page, arc, participation history — and **the prerequisite fix, which is in this phase and not after it.** The app read `/public/modules` nowhere, and `ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so every shard entry rendered and every one of them 404d. Failing open is right *within* a module that exists and wrong for one that is not installed. Six org-lead decisions. Four up front — the failure direction, the screen scope, where history lives, and native deep links; and two on the scope of the second defect below. **One departed from the recommendation:** an inbox link to `/site/events/?run=` **opens the app** rather than a Custom Tab, reusing `resolveWebPath` rather than adding a second link-routing mechanism. That forces the one rule that function was written to enforce — *a query hands off* — to gain an exception, and it is exactly one key on exactly one path. **Absence of an answer is not an answer of absence**, which is the distinction the whole fix rests on: a *successful* `/public/modules` read that does not name `shard` **hides** the rows; a *failed* read keeps the last answer the host gave; a host that has never answered leaves the gate open. The old code collapsed all three into one `null`. Capability and feature are a second and third gate that compose: capability asks *is the module installed* (per host), feature asks *does this shard publish it to this viewer* (per viewer). **A second, older defect was found while scoping and fixed here** (org lead): the app's website path → route table had been wrong since the module-system cutover on 2026-08-12. It quoted a sixteen-row `NAV`; core's is now **eight** and the nine shard rows moved to `/uo/*`, because core mounts a module's pages at `//`. Three live consequences: a nav override on any shard row was ignored, an added link to a shard page handed off to a browser instead of opening natively, and the sort-key number line was wrong. Two tests in the existing suite had been passing **vacuously** since that day — they asserted a section came out empty, and it did, because the item never joined it. **Ships:** the calendar, event pages and participation history in the app. **Verify:** the app suite (**570 tests, 0 failures**); an emulator walk at three rungs. **The walk found three defects and the suite found none of them.** The rig's own site had no `uo` module, which is what made two of them visible. The three player game-data rows read `/player/shard/*` and were not gated, so they rendered and 404d — and the test meant to catch that asked whether every row *with a `feature`* declared the capability, which those three do not have. `score` is `DECIMAL(18,4)` and was declared an integer, so one `318.5` made kotlinx refuse the **entire** body and a 200 rendered as a server error — latent on the public results table for every visitor. And a drawer route's view model outlives a sign-out, so signing in as a second account showed it the first account's participation history with no request made at all. See `docs/android/PLAN.md` M13. --- ### 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. > **Built** as `Integration-kit#10` + `docs#226`, red on `checkCoreApi` by design. **It is three > chapters, not one, and the template gains code.** The book taught a read-only data path end to end > and never told anyone to build a command path, so chapters 3 and 4 gain one section each (§2a; *"A > command that changes the world runs at most once"*), both skippable until you want chapter 5. The > template ships one budget, one option source, one lease and one ledgering action plus > `server/sidecarClient.js` — named for the filename `noGameConnection.test.js` already anticipated, > with a real timeout, a real key passthrough and a simulated transport in one replaceable function. > Running those through core's REAL registry and dispatcher at `edge` is what found the two defects > §F now records; the prose found neither. --- ### 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`. > **Split into 16a (the walk) and 16b (the cutover)** (org lead, 2026-09-09), on the same argument > 12a/12b and 14a/14b were split on. The two sentences above cannot both hold: `link`, > `servuo-plugins` and `Module-uo` all release on push to **`main`**, so no released artefact > carrying events can exist until after the cutover. Engagement Phase 13 met the same wall and > resolved it the other way, cutting over first and walking from `main`. Here the walk goes first > against artefacts built from `edge` exactly the way a release builds them, because every walk in > this workstream has found defects and a defect found on `edge` is a reviewed PR rather than a > hotfix to `main`. **16b re-verifies against the real released bundle** — install, boot, run one > event — so the delivery path is still proved, just second. A third leg, **16c**, carries > `runicgateway.com` and `.profile`. > > **16a WALKED, and it is four repos** — `Module-uo`, `website`, `servuo-plugins`, `docs`. The whole > rig: real ServUO 57.4 (208k items, 42k mobiles) → a `cargo --release` sidecar on protocol 7 → core > with the module installed from a release-shaped bundle → the Android app on an emulator. The > overlay was deployed from a tarball built the way CI builds one, into a tree with `Scripts/Custom/ > Bridge` and `Saves/Bridge` deleted first, so it was a first install rather than an upgrade. > > **All three deliberate failures pass.** (1) A mid-run process kill landed mid-TEARDOWN — sharper > than mid-step, since a phase executes in about a second — with the run `completed`, cleanup > `pending`, a lease half-returned and 21 world objects up: teardown resumed on restart and all 15 > steps still read `attempts = 1`, so nothing re-executed. (2) The sidecar killed during a phase gate > left the run `degraded` rather than failed, `core.lease` retrying with a reason, and the four world > writes behind it **parked at `attempts = 0`**; the shard reconnected on its own. (3) A cap of 5 > against a step asking for 12 answered `refused` — its own status — with `code: "cap"` and *"asks > for 12 of `uo.creatures`; 0 of 5 is already spent this run"*, and the dry run had already refused > it in the author's own words. > > **Six defects, all in code already merged to `edge`, and the suites were green on either side of > every one.** Two were blocking or worse. **The spawn atlas could not import on a stock ServUO > tree** — a case-sensitive JS dedupe against an `..._ai_ci` PRIMARY KEY, four colliding decoration > spellings in ServUO's own files, and the whole transaction lost; with no atlas every option source > answers empty and no world verb can be authored at all. **Teardown of all five world verbs was a > no-op that reported success** — `revertOwned` sent the despawn under the step's key, which is the > key the SPAWN used, so the shard replayed the spawn's reply and `OnDespawn` never ran; the ledger > read `reverted` while the shard held all 21 objects, and the same despawn under a fresh key removed > every one. Then: **the public calendar served neither live nor recent runs** though §I promises all > three, so the site said `live` on one page and showed nothing on the other; **a resource left > `reverting` by a crash was never reclaimed**, and the manual cleanup route answered 200 while doing > nothing, which stranded a lease and blocked the NEXT run of the same event; **a transient refusal > under an idempotency key was permanent**, because the shard's store had no case for a handler that > ran and deliberately did nothing; and **three facts every announcement computes were declared by no > trigger** and silently dropped. > > One reported defect was **withdrawn**: `skip` refusing a `failed` step is not a dead end, because > `resume` carries a run past any settled step — the route's own docs say so and the rig confirmed > it. The runner claims only `pending` steps, so `failed` and `refused` are both settled. > > Every fix is verified against the rig, not only against tests: the atlas imports 309 decor types > and 6,455 points; a full four-phase run's teardown leaves the shard owning **0**; a lease stranded > by a real crash is reclaimed in one sweep and `cleanup_status` reaches `complete`; the same save > key 25 seconds apart answers "15 seconds ago" then "40 seconds ago"; and `/site/events` shows a > live run as **Happening now** beside recent ones, in the browser and in the app. Each new test was > confirmed to FAIL without its fix. > > **`Module-uo`'s `revert` no longer forwards core's key at all** — `MODULE_API.md` now says why that > key identifies a lost dispatch rather than addressing the undo. **Protocol 7 is amended in place**: > a refusal releases its key, with the rule that pays for it written down — *do not answer `*.error` > after changing the world*. **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
first demo"] P3 --> P4["P4 · schedule + calendar"] P2 --> P5["P5 · conditions + diagnosis"] P2 --> P6["P6 · enablement + caps"] P6 --> P7["P7 · module contract
MODULE_API 1.10.0"] P7 --> P8["P8 · ledger + leases + cleanup"] P8 --> P9["P9 · UO wave 1
no protocol change"] P5 --> P10["P10 · integrations"] P9 --> P10 P0 -.->|"§N1 answered"| P11["P11a+b · protocol 6"] 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.