Code: RunicGateway/website#189. MODULE_API.md gains 1.10.0 in three places: the number itself, the entry in Part 1 with the four call shapes and the six rules that come with them, and the four names in 2.4's call list with the contract-rather-than- implementation notes beside them. EVENTS.md F is marked built, with a new subsection recording what the build settled -- the open unit vocabulary, the undeclared-dimension refusal and why it has its own code, why example-pricing survives the arrival of a budget registry, why restore cannot be read, and why an option source that refuses answers 200. K's Phase 7 note becomes what shipped. The API surface table's option-source row is filled in, and the absent-routes list is down to cleanup. EVENTS_PLAN.md: Phase 7 complete, in the shape phases 0-6 use -- the four org-lead decisions, the three things the build settled, the throwaway-module proof, the React defect only the browser could find, and the route the docs caught the code building one segment shallower than this file specifies. Checked by hand (docs has no CI): every anchor resolves, and every route, identifier and file path named here exists on website#189. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
1115 lines
73 KiB
Markdown
1115 lines
73 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`)
|
||
|
||
`event_run_resources` with `lease_until`, the `drifted` status, and
|
||
`UNIQUE (owner_module, kind, ref)` among non-reverted rows — which is what makes two events unable to
|
||
lease one target, and produces a `refused` step at authoring time rather than a corrupted baseline at
|
||
runtime.
|
||
|
||
**Record before confirm.** The step writes a `pending` row, dispatches, and promotes it on the answer.
|
||
Recording afterwards makes every object whose acknowledgement was lost invisible to cleanup forever —
|
||
so reverting a resource that does not exist must be a *success*.
|
||
|
||
Cleanup steps are **generated from the ledger** at teardown, on every terminal path — completion,
|
||
cancellation and abort alike. `cleanup_status` is its own column: a run reaches `completed` with
|
||
`cleanup_status = 'incomplete'` and stays on the admin screen, rather than being held `running`.
|
||
|
||
Reconcile-on-reconnect: the runner asks each ledgered resource's module what is still in force.
|
||
|
||
**Ships:** the safety property the whole world-write half depends on. Also useful on its own — the
|
||
platform gains a durable record of what it changed.
|
||
**Verify:** `npm test`; a rig run that kills the process mid-run and confirms cleanup completes on
|
||
restart; a run whose revert fails and stays visible.
|
||
|
||
---
|
||
|
||
### Phase 9 — UO wave 1: the actions that need no protocol change (`module-uo` + `docs`)
|
||
|
||
`module-uo` registers its first event actions over the write plane that already exists:
|
||
`uo.broadcast`, `uo.towncrier.post`, `uo.news.post`. Option sources answered **from the spawn
|
||
atlas** — `uo.options.regions`, `uo.options.landmarks`, `uo.options.creatures` — which cost nothing
|
||
new and work with the shard down.
|
||
|
||
**Ships:** the first end-to-end event against a real shard: scheduled, announced in-game and on the
|
||
site, cued to a GM, completed, recorded.
|
||
**Verify:** the whole rig — ServUO + sidecar + website — running a real two-phase event.
|
||
|
||
**Trap worth writing into the action declarations.** These three verbs have *different* idempotency.
|
||
`towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is
|
||
not** — a retry is a second announcement to everyone online — and there is no idempotency key on the
|
||
wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
|
||
declaration says why.
|
||
|
||
---
|
||
|
||
### Phase 10 — Integrations (`website` + `docs`)
|
||
|
||
Core registers its own `event.` triggers — `run.scheduled`, `run.started`, `phase.changed`,
|
||
`run.ending`, `run.completed`, `run.cancelled`, and `run.failed` at `ceiling: 'admin'` — with seeded
|
||
templates through the mechanism `registerEngagementSeeds` already provides. Events owns **none** of the
|
||
delivery.
|
||
|
||
`event_run_participants`, results publication, and the `core.announce.post` action that links an
|
||
existing post to a run and enqueues it through `announce_jobs` — so the in-game town crier and Discord
|
||
both come free as already-registered legs with retry and classification.
|
||
|
||
`ENGAGEMENT.md` §8.6's row — *"a scheduled event is starting · ❌ needs a manual/scheduled trigger
|
||
type"* — is resolved in this PR.
|
||
|
||
**Ships:** every announcement channel the platform has, for every event, per user preference.
|
||
**Verify:** `npm test`; a mail-catcher rig confirming an event announcement reaches email, in-app and
|
||
push; the ceiling on `run.failed` proved to exclude a moderator.
|
||
|
||
---
|
||
|
||
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`)
|
||
|
||
**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked.
|
||
|
||
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a
|
||
repeat with the original result; a lease deadline timer that restores baseline **without being
|
||
asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind.
|
||
- **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar
|
||
and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to
|
||
compose.
|
||
- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served.
|
||
- **`installer`:** joins the phase because of the pairing above.
|
||
- **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`.
|
||
|
||
**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both
|
||
are safety properties, not features.
|
||
**Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new
|
||
code is live; a deliberate duplicate command; a lease whose website is killed before it expires,
|
||
proving baseline returns anyway.
|
||
|
||
---
|
||
|
||
### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`)
|
||
|
||
**§N1 answered 2026-09-01 — no longer gated**, and taken in full, so the item grant row below
|
||
stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and
|
||
either owned or borrowed:
|
||
|
||
| Verb | Owned or borrowed | Cap dimension |
|
||
| --- | --- | --- |
|
||
| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | `uo.creatures` |
|
||
| "Simple" boss variants, as event-owned creature templates | owned | `uo.bosses` |
|
||
| Oracle NPCs with scripted dialogue | owned | `uo.npcs` |
|
||
| Temporary gates | owned, with a deadline | `uo.gate.minutes` |
|
||
| Temporary decoration lockdown | owned | `uo.decor` |
|
||
| A live config value | **borrowed** — a lease | — |
|
||
| A property on an existing object | **borrowed** — a lease | — |
|
||
| Seasonal-event toggle | borrowed | — |
|
||
| World save | neither — a one-shot | — |
|
||
| Item grant | owned, `reversible: 'none'` | `uo.rewards` |
|
||
|
||
**The lease allowlist ships with a boot-time self-check.** 258 `Config.Get` call sites in ServUO split
|
||
between live reads and values cached at type initialisation, and a lease on the second kind applies
|
||
cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the
|
||
advertised catalog if it does not take — a capability that disappears loudly beats one that lies.
|
||
|
||
**Ships:** the invasion.
|
||
**Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown
|
||
back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming `drifted`
|
||
rather than a silent revert of their change.
|
||
|
||
---
|
||
|
||
### Phase 13 — The authoring UI proper (`website`)
|
||
|
||
Replaces P3's placeholders. The phase timeline (**not** a node graph — the condition grammar has no
|
||
branching and a canvas would advertise power the engine does not have); the step editor rendering each
|
||
action's declared params, with option-source dropdowns; the live cap meter; dry run; rehearsal.
|
||
|
||
**Ships:** an administrator can build the invasion without touching JSON, which is the acceptance
|
||
criterion the whole feature was asked for.
|
||
**Verify:** the client suite; a browser walk authoring a multi-phase event end to end with no raw JSON
|
||
at any point.
|
||
|
||
---
|
||
|
||
### Phase 14 — Public, player and mobile (`website` + `android-app` + `docs`)
|
||
|
||
`GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`,
|
||
`/player/events/history`, and an `events` capability string.
|
||
|
||
**One prerequisite fix, in this phase and not after it.** The Android app hardcodes
|
||
`api/v1/public/shard/stream` — a *module* path — and reads `/public/modules` nowhere, so it cannot
|
||
render a site whose module it has never heard of. That is a small fix and it blocks the app seeing
|
||
events at all.
|
||
|
||
**Ships:** the public calendar, event pages with their storyline and results, participation history,
|
||
and the app.
|
||
**Verify:** the client and app suites; an emulator walk.
|
||
|
||
---
|
||
|
||
### Phase 15 — Integration Kit chapter 5 (`integration-kit`)
|
||
|
||
An event-capable module. Teach and link out; re-specify nothing — `EVENTS.md` and `MODULE_API.md` stay
|
||
normative.
|
||
|
||
The four things a second module's author will get wrong, one paragraph each, all invisible until an
|
||
outage: the envelope's failure default, the idempotency passthrough, recording a resource **before**
|
||
confirming it, and under-declaring `cost`.
|
||
|
||
**Like Teams Phase 11, this cannot merge until the cutover exists** — the kit is pinned to a `main`
|
||
sha, and the contract it teaches is not on `main` until then.
|
||
|
||
---
|
||
|
||
### Phase 16 — Acceptance walk and cutover
|
||
|
||
**The walk first, against released artefacts, not a working tree.** The whole rig — ServUO, sidecar,
|
||
website, emulator — running a real multi-phase event, including three deliberate failures:
|
||
|
||
1. a mid-run process restart, proving the run resumes and no step double-executes;
|
||
2. a sidecar kill mid-phase, proving the run degrades rather than failing and world writes park;
|
||
3. a cap breach, proving `refused` and an author who is told why.
|
||
|
||
Then `edge` → `main`, in the order every previous cutover used: the protocol side first, the module,
|
||
core, docs, then the kit's re-pin and `runicgateway.com`.
|
||
|
||
**Two documents that are cutover-window work by construction.**
|
||
- **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until
|
||
the cutover lands. Same 12a/12b split the engagement workstream needed.
|
||
- **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new
|
||
subsystem is.
|
||
|
||
---
|
||
|
||
## What this plan does not do
|
||
|
||
Stated so the omissions are choices rather than oversights.
|
||
|
||
- **No node-graph editor** (P13). The engine has no branching.
|
||
- **No delegation, grants or proposal queue.** Permissions gate on the existing admin roles. P6's
|
||
`mayInvoke` keeps it a cheap later option.
|
||
- **No points.** No core points system exists and no write path to any loyalty board exists.
|
||
- **No event invoking another event.** It already works by composition — a second event's condition can
|
||
be `event.run.completed` — and a direct edge would need cycle detection and a story about cancelling
|
||
a parent.
|
||
- **No core SSE.** The run console polls. Adding a live channel to core is a larger change than this
|
||
feature needs, and if it is ever built it should be core infrastructure with module-uo's two streams
|
||
migrating onto it.
|
||
- **No mutation of shard-owned content without a baseline.** Loot tables and arbitrary
|
||
`[set` / `[get` / `[add` stay excluded.
|
||
|
||
---
|
||
|
||
## Dependency summary
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
P0["P0 · design of record"] --> P1["P1 · schema + registry"]
|
||
P1 --> P2["P2 · the runner"]
|
||
P2 --> P3["P3 · minimal admin UI<br/><b>first demo</b>"]
|
||
P3 --> P4["P4 · schedule + calendar"]
|
||
P2 --> P5["P5 · conditions + diagnosis"]
|
||
P2 --> P6["P6 · enablement + caps"]
|
||
P6 --> P7["P7 · module contract<br/>MODULE_API 1.10.0"]
|
||
P7 --> P8["P8 · ledger + leases + cleanup"]
|
||
P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
|
||
P5 --> P10["P10 · integrations"]
|
||
P9 --> P10
|
||
P0 -.->|"§N1 answered"| P11["P11 · protocol bump"]
|
||
P8 --> P11
|
||
P11 --> P12["P12 · UO world verbs"]
|
||
P7 --> P13["P13 · authoring UI"]
|
||
P4 --> P13
|
||
P10 --> P14["P14 · public + mobile"]
|
||
P13 --> P16["P16 · walk + cutover"]
|
||
P12 --> P16
|
||
P14 --> P16
|
||
P7 --> P15["P15 · kit chapter 5"]
|
||
P15 --> P16
|
||
```
|
||
|
||
**The critical path is P0 → P1 → P2 → P6 → P7 → P8 → P11 → P12 → P16.** Everything else can run
|
||
beside it. P3, P4, P5 and P10 are the phases that could be picked up by a second pair of hands without
|
||
blocking the spine.
|
||
|
||
---
|
||
|
||
## If only part of this gets built
|
||
|
||
Three defensible stopping points, in case scope has to shrink.
|
||
|
||
**Stop after P5** — a scheduled, multi-phase, condition-advanced event engine that announces through
|
||
core's own actions and cues a human for anything in-world. No module contract, no ledger, no protocol
|
||
change. This is a real product and it is roughly a third of the work.
|
||
|
||
**Stop after P10** — everything above plus the module seam, the ledger, the first UO actions and every
|
||
notification channel. The website orchestrates and announces; a GM does the target-driven parts
|
||
in-client, exactly as `ADMIN_CONTROLS.md` §8 argued they should. **This is the recommended minimum
|
||
viable scope**, and it was the scope that required no answer to §N1 at all. §N1 has since been
|
||
answered in full, so this is now a scope choice rather than a way around an open decision.
|
||
|
||
**Stop after P14** — everything, including the world verbs and the public calendar. P15 and P16 are
|
||
not optional if anything ships to `main`; they are the cost of the `edge` discipline.
|