Events Phase 14b, docs half. `docs/android/PLAN.md` gains **M13** — written before the code, as the plan requires — and `EVENTS_PLAN.md` and `EVENTS.md` record 14b as built. M13 records the six org-lead decisions, the two defects the milestone fixes, and the three the emulator walk found. The prerequisite fix is the larger half of the milestone: the app could not tell a module that is not installed from a lookup that failed, so on a site with no `uo` module every shard row rendered and every one of them 404'd. **Absence of an answer is not an answer of absence** — a successful module list that omits the string hides the rows, a failed read keeps the last answer the host gave, and a host that has never answered leaves the gate open. Capability and feature are two gates that compose and answer different questions. A second, older defect is recorded and fixed: the website path → route table had been wrong since the module-system cutover on 2026-08-12, because core's `NAV` is eight rows rather than sixteen and the nine shard rows moved to `/uo/*`. Two tests in the app's suite had been passing vacuously since that day. §5 gains the capability gate beside the feature gate, with the two failure directions spelled out; §6.1 gains the three public event reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1890 lines
132 KiB
Markdown
1890 lines
132 KiB
Markdown
# The Event System — phased implementation plan
|
||
|
||
**Derived from** [`EVENTS.md`](EVENTS.md) revision 5, which is the design of record. This document
|
||
decides *order*: what lands in which pull request, what each one ships on its own merit, and how each
|
||
is proved. It re-specifies nothing — where this and `EVENTS.md` disagree, `EVENTS.md` wins and this
|
||
file is the one with the bug.
|
||
|
||
**Seventeen phases, P0–P16.** Every phase is independently shippable and leaves the site working.
|
||
**No phase is gated any more.** P11 and P12 hung on `EVENTS.md` §N1 (the `ADMIN_CONTROLS.md` §8
|
||
amendment); §N1 and the other ten **were answered by the org lead on 2026-09-01**, which closes P0
|
||
and lifts the gate. They stay deliberately late all the same — they are the phases that reach into
|
||
the world, and they belong after the ledger that makes them safe. Everything else — the engine, the
|
||
scheduler, the calendar, the conditions, the caps, the module contract, the ledger, the first wave of
|
||
UO actions, the integrations, the authoring UI, the public surface — needed no decision beyond P0.
|
||
Those fourteen phases reach the game only to *announce*, over verbs the write plane already carries; nothing in them creates or
|
||
changes a thing in the world.
|
||
|
||
---
|
||
|
||
## Before anything: three facts about the ground
|
||
|
||
**1. `edge` is free, and it is stale.** The engagement workstream is fully cut over as of 2026-09-01 —
|
||
website `main` at `6331b36` (engagement Phase 14, retention), docs `#205` merged, `module-uo`'s `ci/core-ref.json`
|
||
re-pinned to `main`. No pull request is open in any of the six repos bar an automated
|
||
`PROJECT_TREE.md` sync.
|
||
|
||
But `edge` is **0 commits ahead of `main` and several behind it** in `website`, `docs` and
|
||
`module-uo` — the cutover merged `edge` into `main` and nothing moved `edge` afterwards. So the first
|
||
act of P1 is to **reset each `edge` to its `main`**, not to branch off it as it stands; branching off
|
||
a stale `edge` would silently revert the engagement retention work the moment this workstream cut
|
||
over. Verify per repo before starting:
|
||
|
||
```bash
|
||
git rev-list --count origin/main..origin/edge # must be 0
|
||
git rev-list --count origin/edge..origin/main # if > 0, edge is stale — reset it
|
||
```
|
||
|
||
**2. A `MODULE_API_VERSION` bump turns the integration kit red, on purpose.** `ci/core-ref.json` pins
|
||
a `main` sha and `checkCoreApi.js` asserts **equality** with what that sha declares. P7 bumps the
|
||
contract to 1.10.0, so the kit goes red from P7 until the cutover re-pins it. That is the mechanism
|
||
working — it forces someone to re-read the chapters — and it must be stated in P7's PR body so nobody
|
||
"fixes" it. The same is true of `module-uo`'s frozen-manifest check for the length of the `edge`
|
||
window.
|
||
|
||
**3. `servuo-plugins` has no CI build.** The plugin compiles only inside ServUO, and the dynamic
|
||
rebuild can silently reload a stale `Scripts.dll` — so "it booted clean" is not evidence the new code
|
||
is live. Every plugin-touching phase (P11, P12) verifies against the local tree at
|
||
`C:\Users\colby\Desktop\ServUO` with a build-offline-first step, and confirms new code is live rather
|
||
than trusting a clean boot.
|
||
|
||
---
|
||
|
||
## Cross-cutting obligations, every phase
|
||
|
||
Not repeated per phase below. A PR that skips one of these is not done.
|
||
|
||
| Obligation | Command / rule |
|
||
| --- | --- |
|
||
| `docs/` updated in the same PR | CLAUDE.md: a code change is not complete until `docs/` reflects it |
|
||
| Server tests | `cd website/server && npm test` |
|
||
| OpenAPI regenerated when a route changed | `npm run swagger` → committed `swagger/swagger-output.json` |
|
||
| Route manifest proves no URL moved | `npm run routes:manifest`, zero-line diff in the PR |
|
||
| No game vocabulary in core | `npm run check:modules` (website root) |
|
||
| module-uo, when touched | `npm run check:imports`, `npm run check:swagger`, `npm run build --prefix client` **before** `npm run check:externals`, then both test suites |
|
||
| Conventional Commits + AI disclosure | `type(scope): summary`; `Co-Authored-By:` trailer; tick the PR-template box |
|
||
| Branch | from an up-to-date `edge`, never a stale one |
|
||
|
||
---
|
||
|
||
## Ordering rationale — why this shape
|
||
|
||
Three choices in the ordering are deliberate and worth stating, because the obvious alternative is
|
||
wrong in each case.
|
||
|
||
**A demoable surface lands at P3, not at P13.** The rich authoring UI is late (P13) and a *minimal*
|
||
admin surface is early (P3). Splitting the UI in two looks like duplicated work and is not: this
|
||
project reviews in a browser — the org lead's own review stack exists for exactly that — and ten
|
||
backend phases with nothing to click is ten phases without feedback. P3 is a list, a form, a start
|
||
button and a run console. P13 is the timeline editor, the option-source dropdowns and the cap meter,
|
||
built once the schemas they render actually exist.
|
||
|
||
**The module contract (P7) comes after the engine, not before it.** The temptation is to design the
|
||
seam first. But `registerEventActions` is shaped by what the runner actually needs to hand an action
|
||
and what it does with the answer, and both are unknown until P2 and P6 are built. Core registers its
|
||
own `core.announce` / `core.wait` / `core.cue` through the *same* registry from P1 — exactly as
|
||
`registries.registerCore()` already does for streams and triggers — so the seam is exercised on every
|
||
boot long before a module uses it, and P7 is a generalisation of something working rather than a
|
||
guess.
|
||
|
||
**Caps (P6) come before the module contract, and before any world write.** A cap is cheap to add to an
|
||
empty system and expensive to retrofit onto a live one, and it is the single control that bounds the
|
||
two failures scheduling makes worse: a typo and a compromised session. P6 is a small phase placed
|
||
early on purpose.
|
||
|
||
---
|
||
|
||
## The phases
|
||
|
||
### Phase 0 — Design of record (`docs`)
|
||
|
||
> **Complete, in two pull requests.** The first introduced this file and `EVENTS.md`; the second
|
||
> recorded the eleven decisions and amended the two documents they reach into. **All of §N1–N11
|
||
> were answered by the org lead on 2026-09-01**, before any code, so nothing below is gated.
|
||
|
||
Land `docs/website/EVENTS.md`, and answer §N1–N11. Two of those answers have consequences outside this
|
||
document and should be written where they will be found:
|
||
|
||
- **N1** is an amendment to [`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) §8, recorded
|
||
there as an amendment with its date and reasoning — **not** a silent reversal. §8 is cited by name
|
||
in four places; a decision that contradicts it and leaves it standing is worse than either decision.
|
||
- **N3** (does the event runner become the `kind: 'scheduled'` evaluator) closes
|
||
`ENGAGEMENT.md` §7.1 Q6, which currently reads "no evaluator yet". If the answer is yes, that row
|
||
changes in the same PR.
|
||
|
||
**Ships:** the design of record, and two documents that stop contradicting each other.
|
||
**Verify:** `docs` CI link check; every relative link resolves.
|
||
|
||
> **As answered, 2026-09-01.** Nine of the eleven went the way `EVENTS.md` §N recommended. **Two did
|
||
> not, and each changes a phase below.**
|
||
>
|
||
> - **N2 — starting a run is `admin` only.** A moderator gets live control of a run already in
|
||
> flight — cancel and abort — and nothing more. Starting commits the deployment to everything a
|
||
> definition contains, unattended; cancelling is incident response, and gating the stop button on
|
||
> the same role as the start button would behave badly in exactly the case moderators exist for.
|
||
> **P3 and P6 carry this**, and `EVENTS.md` §K's table is the normative statement of it.
|
||
> - **N4 — this deployment is single-instance, and not planned to change.** **P2 does not build the
|
||
> `--scale app=2` test.** Every claim path is built exactly as specified regardless: the unique
|
||
> index and the CAS equally protect a tick that overruns into the next one, and the lease and its
|
||
> reclaim recover a step whose process died mid-dispatch. The multi-instance property becomes
|
||
> true-by-construction rather than proved; `EVENTS.md` §E records what to build first if this
|
||
> deployment is ever scaled or acquires a rolling deploy.
|
||
>
|
||
> **N1 was taken in full, item grants included** — a reversal of *two* separate lines in
|
||
> `ADMIN_CONTROLS.md` §8 rather than one, and the amendment at §8a says both out loud.
|
||
|
||
---
|
||
|
||
### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`)
|
||
|
||
> **Complete.** `edge` in `website` and `docs`. Six tables, thirteen routes, the action registry with
|
||
> core as its first registrant, and 44 tests. **Nothing dispatches** — a run row is created and stays
|
||
> `scheduled`, which is this phase's correct answer and is rendered as such.
|
||
>
|
||
> **Four things the build settled that the plan had left open, each recorded in `EVENTS.md`:**
|
||
>
|
||
> - **`event_definitions` gained a `spec` column.** §D's column list does not name one, because §D
|
||
> describes what a published event is made of. But "editing a draft is free; no version exists yet"
|
||
> means the working copy has to live somewhere, and it cannot be an `event_versions` row: that table
|
||
> is immutable and a run pins one. Publishing copies the column into a version and leaves it as the
|
||
> next draft.
|
||
> - **The spec validator must accept its own output**, and a test found it did not. `validate()` adds
|
||
> `actionVersion` and `dormant`, then refused them as unknown keys on the next call — which would
|
||
> have made the *second* save of any definition, and publish's own re-validation, impossible. Both
|
||
> are now accepted and recomputed rather than trusted.
|
||
> - **A param's `example` is required**, on optional params too, matching `registerEventTriggers`. It
|
||
> is the authoring form's placeholder and there is no other source for one.
|
||
> - **Two routes the §API-surface table did not name**: `GET /admin/events/:id` (the list serves a
|
||
> summary; the editor needs the tree) and `GET /admin/events/series` (a form cannot offer a value it
|
||
> cannot enumerate). Both are staff reads over data the list already exposes.
|
||
>
|
||
> **Two deliberate absences, both stated so a reviewer does not read them as gaps.** The live run
|
||
> controls and `verify` are not stubbed — nothing is in flight until P2, and a control that answers
|
||
> `200` and does nothing is worse than one that is not there. And core's three `perform()` bodies
|
||
> answer `{ ok: false, retry: false }` rather than `{ ok: true }`: `ok: true` on an action that did
|
||
> nothing is a recorded world change that did not occur, which is the exact mistake §F's failure
|
||
> default exists to prevent.
|
||
>
|
||
> `registerEventActions` is on the staging area and reachable **only** by `registerCore()` — the
|
||
> loader builds its own `api` facade and has no method that delegates to it, so no module can call it
|
||
> yet and `MODULE_API_VERSION` is untouched. P7 adds that facade and makes the bump.
|
||
|
||
The six tables that do not depend on the module contract: `event_definitions`, `event_series`,
|
||
`event_versions`, `event_runs`, `event_run_steps`, `event_run_log`. Admin CRUD, publish (which
|
||
snapshots a version), archive. `router/v1/admin/events.router.js` + `events.controller.js`, models as
|
||
`.model.js` / `.db.js` pairs under `model/events/`.
|
||
|
||
**The registry lands here, with core as its first registrant.** `modules/registries.js` gains
|
||
`registerEventActions` staging and commit, and `registries.registerCore()` registers three core-owned
|
||
actions: `core.announce` (post to an announce leg / broadcast target), `core.wait` (a timed no-op) and
|
||
`core.cue` (post an instruction and wait for a human). None of them execute yet — P2 is what runs a
|
||
step — but the registry, the id grammar, the risk classes and the param validation are all live and
|
||
exercised on every boot.
|
||
|
||
**Ships:** nothing user-visible; the site is unchanged. A run row can be created and stays
|
||
`scheduled` forever, which is correct for this phase and must be visible as such rather than looking
|
||
broken.
|
||
**Verify:** `npm test`; the registry's collision and validation paths tested the way
|
||
`registries.js`'s existing members are; routes manifest and swagger regenerated.
|
||
|
||
**Trap:** `register()` must not touch the database (`MODULE_API.md` §2.2) — `routeManifest.js` and
|
||
`swagger.js` both require `app.js` against a dead pool. Core's own action registration is subject to
|
||
the same rule.
|
||
|
||
---
|
||
|
||
### Phase 2 — The runner (`website`)
|
||
|
||
> **Complete.** `edge` in `website`. The eighth poller, the two CAS claims, the lease and its
|
||
> reclaim, the grace window, and the three core actions given real bodies. **A published event
|
||
> started from the existing run route now announces, waits and completes on its own** — the phase's
|
||
> shipped claim, and it adds no routes to do it.
|
||
>
|
||
> **Four things the org lead settled that the plan and §E had left open** (2026-09-02), each written
|
||
> into `EVENTS.md`:
|
||
>
|
||
> - **A parked step is `running` with a NULL lease.** `event_run_steps.status` has no state for
|
||
> "waiting on a human", and adding one would be a table ALTER that `CREATE TABLE IF NOT EXISTS`
|
||
> never delivers to an existing deployment. So the reclaim was written to take back only a lease
|
||
> that is **non-NULL and expired**, and a NULL one means parked. A cue posted on Friday is still
|
||
> waiting on Monday.
|
||
> - **Two success-envelope members, not two special cases.** `{ ok: true, await: 'human' }` parks;
|
||
> `{ ok: true, holdFor: <seconds> }` finishes and delays what follows. The runner never names an
|
||
> action id, and Phase 7 hands a module the same door.
|
||
> - **A run whose concurrency key is held stays `scheduled`** and lets its own grace window decide,
|
||
> rather than failing at once or queueing indefinitely.
|
||
> - **`n` in §L's `retry(n)` is a runner constant** — `EVENT_STEP_MAX_ATTEMPTS`, 3, with a flat
|
||
> 60s backoff — rather than a column or a spec field.
|
||
>
|
||
> **Three things the build settled on its own, all worth a look:**
|
||
>
|
||
> - **All three `on_failure` dispositions write the STEP `failed`.** The disposition governs the RUN.
|
||
> `skipped` is left for a human's skip control in Phase 3, because a status meaning both "nobody ran
|
||
> this" and "this failed and we moved on" makes the console's summary line unreadable.
|
||
> - **A live lease is not re-enterable, not even by the process that took it.** The first draft of
|
||
> `claimTick` carried an `OR claimed_by = ?` escape for a tick re-entering its own claim — which is
|
||
> precisely the overrun this phase's CAS is meant to protect against, since `setInterval` fires
|
||
> whether or not the last callback returned. The clause is gone, a `releaseClaim` hands a still-
|
||
> in-flight run back at the end of a tick (without it every `core.wait` would become
|
||
> `max(wait, leaseMs)`), and an in-process `ticking` guard skips an interval that would overlap.
|
||
> - **A wait as the last step of a phase holds the NEXT phase.** The first implementation set the
|
||
> following step's `due_at` and stopped there, so a trailing wait — "announce, wait five minutes,
|
||
> then phase 2" — silently meant nothing, because the next phase's steps are not materialised until
|
||
> the run enters it. The instant is now carried across the boundary. Found by writing the test, and
|
||
> the test was re-run against the unfixed code to confirm it fails.
|
||
>
|
||
> **What "materialise" 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: '<triggerId>', where: <conditions>, count: n }`, reusing `engagement/conditions.js`
|
||
> **unchanged** — its grammar, its type checking against the declaration, its depth and list bounds,
|
||
> and its operator labels. `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:<runId>` 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 `<owner>.` and rule trigger ids namespaced likewise, while core's own
|
||
> bodies live in `engagement/templateSeeds.js` and its rules in `engagement/coreRules.js` under a
|
||
> per-group one-shot settings key. Phase 10 uses core's mechanism, with a THIRD key — the rule
|
||
> `ENGAGEMENT.md` Phase 11 established, because appending to the Team or news list would seed these
|
||
> on fresh installs only and on exactly the upgrades that want them, never.
|
||
> - **The ceilings are §J's, not this section's.** The paragraph above reads as though all seven sit
|
||
> at `admin`; §J says six are public and only `run.failed` is. §J is right and this is now built
|
||
> that way: six at ceiling `authenticated` / default audience `subscribers`, exactly where
|
||
> `news.post` sits, and `run.failed` at `admin` on both halves because a failure names the
|
||
> deployment's own broken machinery.
|
||
> - **The row is `ENGAGEMENT.md` §8.5, not §8.6.** §8.6 is `module-uo`'s trigger catalogue.
|
||
> - **Two rules are seeded, not seven.** All seven triggers are DECLARED, so an operator can write a
|
||
> rule against any of them; what is seeded is the pair somebody would otherwise build on the first
|
||
> day. Seven disabled rows would bury the two that matter, and `event.phase.changed` is the one
|
||
> most likely to be switched on by accident and then mail a player four times in an evening.
|
||
>
|
||
> **Two design notes worth carrying forward.**
|
||
>
|
||
> - **None of the six public triggers declares a `url` variable, and that is deliberate.** There is no
|
||
> public event page until Phase 14 — `App.jsx` mounts nothing under `/site/events` — and `news.post`
|
||
> has already paid for this mistake once: its `postUrl` example named a path that did not exist, and
|
||
> the template editor previewed a link that was dead in every mail it sent. Phase 14 adds the
|
||
> variable alongside the page it points at, which is a version bump. `event.run.failed` is the
|
||
> exception because `/admin/events/runs/:runId` exists today.
|
||
> **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
|
||
`<lease id>#<target>` — 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 `<UniqueId>` 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/<slug>?run=<id>` **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 `/<id>/<path>`. 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.
|
||
|
||
---
|
||
|
||
### Phase 16 — Acceptance walk and cutover
|
||
|
||
**The walk first, against released artefacts, not a working tree.** The whole rig — ServUO, sidecar,
|
||
website, emulator — running a real multi-phase event, including three deliberate failures:
|
||
|
||
1. a mid-run process restart, proving the run resumes and no step double-executes;
|
||
2. a sidecar kill mid-phase, proving the run degrades rather than failing and world writes park;
|
||
3. a cap breach, proving `refused` and an author who is told why.
|
||
|
||
Then `edge` → `main`, in the order every previous cutover used: the protocol side first, the module,
|
||
core, docs, then the kit's re-pin and `runicgateway.com`.
|
||
|
||
**Two documents that are cutover-window work by construction.**
|
||
- **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until
|
||
the cutover lands. Same 12a/12b split the engagement workstream needed.
|
||
- **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new
|
||
subsystem is.
|
||
|
||
---
|
||
|
||
## What this plan does not do
|
||
|
||
Stated so the omissions are choices rather than oversights.
|
||
|
||
- **No node-graph editor** (P13). The engine has no branching.
|
||
- **No delegation, grants or proposal queue.** Permissions gate on the existing admin roles. P6's
|
||
`mayInvoke` keeps it a cheap later option.
|
||
- **No points.** No core points system exists and no write path to any loyalty board exists.
|
||
- **No event invoking another event.** It already works by composition — a second event's condition can
|
||
be `event.run.completed` — and a direct edge would need cycle detection and a story about cancelling
|
||
a parent.
|
||
- **No core SSE.** The run console polls. Adding a live channel to core is a larger change than this
|
||
feature needs, and if it is ever built it should be core infrastructure with module-uo's two streams
|
||
migrating onto it.
|
||
- **No mutation of shard-owned content without a baseline.** Loot tables and arbitrary
|
||
`[set` / `[get` / `[add` stay excluded.
|
||
|
||
---
|
||
|
||
## Dependency summary
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
P0["P0 · design of record"] --> P1["P1 · schema + registry"]
|
||
P1 --> P2["P2 · the runner"]
|
||
P2 --> P3["P3 · minimal admin UI<br/><b>first demo</b>"]
|
||
P3 --> P4["P4 · schedule + calendar"]
|
||
P2 --> P5["P5 · conditions + diagnosis"]
|
||
P2 --> P6["P6 · enablement + caps"]
|
||
P6 --> P7["P7 · module contract<br/>MODULE_API 1.10.0"]
|
||
P7 --> P8["P8 · ledger + leases + cleanup"]
|
||
P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
|
||
P5 --> P10["P10 · integrations"]
|
||
P9 --> P10
|
||
P0 -.->|"§N1 answered"| P11["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.
|