The last leg of the events plan, and the record of what it turned up. **16c began by closing 16b's missing seventh step.** Six repositories were cut over and every one showed 0 commits on `edge` that are not on `main`. This one showed 46: step 6 landed the record *on* `edge` rather than cutting `edge` over, so `docs` `main` opened `EVENTS.md` with "revision 5. No code written. Read against ... MODULE_API_VERSION 1.9.0 - sidecar protocol 5" while six repositories shipped the engine on protocol 7 — and `link/v6.md` and `v7.md` existed on no default branch anywhere. Nothing in this workstream could have caught that, and the reason is worth writing down: every check that guards a contract lives in the repository that DEPENDS on the contract, and a documentation repository has no dependants. The one check that reads `docs` from outside is `runicgateway.com`'s `checkReference.mjs`, and 16c is the only phase that would ever have run it. Closed by #232. **The site.** Nine `checkFacts` values and twenty-seven `Bridge.cfg` keys, both red before the phase started — which is the bargain that repository's §12 struck. Two pages, the treatment Teams has. Two capability entries, the calendar one deliberately not `needsModule` because a bare core can author and run an event. A `deploy-events` privacy row, because the participation ledger is personal data and nothing named it. And `reference/event-catalog` retitled "Shard event catalog", route unchanged, because two things in the documentation were called an event catalog. **`.profile`.** One bullet saying the posture rather than the feature list, and two stale numbers: protocol 5 → 7 in the four values the installer prints, and module-uo v1.1.0 → v1.2.2. **And the plan is marked COMPLETE.** All seventeen phases on `main`, in every repository they touch. No contract moves in this phase; it makes the ones already moved legible from outside the organisation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
154 KiB
The Event System — phased implementation plan
Derived from 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.
COMPLETE as of 2026-09-10. All seventeen phases are built and on main in every repository they
touch. P16 ran as three legs — 16a the acceptance walk from edge, 16b the six-step cutover
and the re-verify against released artefacts, 16c runicgateway.com and .profile — and 16c
opened by closing the seventh cutover step 16b had left standing (docs#232). The platform the
workstream leaves behind is sidecar v2.2.0, overlay v1.2.0, bundle 2026.09.10 on
protocol 7, Module-uo v1.2.2, and MODULE_API_VERSION 1.10.0. Each phase's record is
in its own section below; edge stays standing, unused, in every repository.
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:
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§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) closesENGAGEMENT.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
adminonly. 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, andEVENTS.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=2test. 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.
edgeinwebsiteanddocs. Six tables, thirteen routes, the action registry with core as its first registrant, and 44 tests. Nothing dispatches — a run row is created and staysscheduled, 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_definitionsgained aspeccolumn. §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 anevent_versionsrow: 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()addsactionVersionanddormant, 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
exampleis required, on optional params too, matchingregisterEventTriggers. 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) andGET /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
verifyare not stubbed — nothing is in flight until P2, and a control that answers200and does nothing is worse than one that is not there. And core's threeperform()bodies answer{ ok: false, retry: false }rather than{ ok: true }:ok: trueon 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.
registerEventActionsis on the staging area and reachable only byregisterCore()— the loader builds its ownapifacade and has no method that delegates to it, so no module can call it yet andMODULE_API_VERSIONis 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.
edgeinwebsite. 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
runningwith a NULL lease.event_run_steps.statushas no state for "waiting on a human", and adding one would be a table ALTER thatCREATE TABLE IF NOT EXISTSnever 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
scheduledand lets its own grace window decide, rather than failing at once or queueing indefinitely.nin §L'sretry(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_failuredispositions write the STEPfailed. The disposition governs the RUN.skippedis 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
claimTickcarried anOR 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, sincesetIntervalfires whether or not the last callback returned. The clause is gone, areleaseClaimhands a still- in-flight run back at the end of a tick (without it everycore.waitwould becomemax(wait, leaseMs)), and an in-processtickingguard 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_atand 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 isengagementManifest.test.js, pre-existing and environmental (engagement-triggers.jsonis CRLF in a Windows tree undercore.autocrlf=truewhile the generator writes LF; content identical, green on CI, confirmed still failing with this branch stashed). 39 new tests acrosseventRunner.test.jsandeventRunnerSql.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:manifestandswaggerregenerated to a zero-line diff — the runner has no surface.check:modulesclean.Trap for anyone running the suite on this machine:
server/modules/uois installed here, so the core suite and both generators need an emptyMODULES_DIR. Without itrouteManifest.test.jsfails 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 ineventRunnerSql.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 defaultsfoundRows: trueand a no-op UPDATE reports 1 rather than 0. A stub can only ever agree with whoever wrote it. Run it with: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 madeMAX_ATTEMPTSunreachable, 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.
edgeinwebsite. 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,cancelon a run;confirm,skip,retryon a step.advanceis 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.cleanupneeds Phase 8's ledger.- Cancel takes
{ reason }, not{ cleanup, reason }. The flag arrives with the thing it would act on. Acleanup: falsethat 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 herestaff, 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.
advanceRundrains up toEVENT_STEPS_PER_TICKsteps 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
seqthat is not settled, which looks equivalent to "the step the run is stopped at" and is not:nextOpenStepselectspendingandrunningonly, so the runner steps over afailedstep. 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 sitspendingfor ever. The rule is nowMAX(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.attemptsreturns 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
confirmtakes 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. SowaitingStepsis 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-existingengagementManifest.test.jsCRLF 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 ineventRunnerSql.test.jsproving the four new statements against a real MariaDB, 2 more ineventRunner.test.js, andeventAuthoring.test.js(20) on the client.routes:manifestandswaggerregenerated — 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.
edgeinwebsiteanddocs. The closed recurrence shapes —once,weekly,monthly(nth weekday),manual— computed in the definition's IANA timezone and stored as UTC inscheduled_for;event_seriesis 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.nthis 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.-1is not a synonym for4.- 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 - graceis the window start, notnow. An occurrence nobody ever materialised is never invented retroactively — three days down must not manufacture three days ofmissedhistory 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.
readyalready means "a version has been published and the schedule is live", so a second enabled flag would be another answer to a questionstateanswers. 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()madeeventRunner.test.jsreach the dead-port pool on every tick — the file passed and took minutes. StubbingfindSchedulablethere 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-existingengagementManifest.test.jsCRLF failure (edgebefore this branch: 1714/1662/51/1). 54 new tests, of whicheventRecurrence.test.js(17) is the DST fixture set this plan asked for, and 5 ineventRunnerSql.test.jsprovefindSchedulable,listInWindowandrepinScheduledagainst a real MariaDB (32/32 with a database, skipped without). Client: 354 pass. Four routes added, none moved;check:modulesandcheck:hostsclean; 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.
edgeinwebsiteanddocs. Phase advance on{ after: '30m' }and on{ on: '<triggerId>', where: <conditions>, count: n }, reusingengagement/conditions.jsunchanged — its grammar, its type checking against the declaration, its depth and list bounds, and its operator labels.event_run_loggainedphase.gate,condition.evaluated(written for both outcomes) andphase.advanced. The diagnosis panel is the phase's real deliverable, andPOST /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_logwould 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) takeshealthto §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 goesmissedbehind 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 besideengine.dispatchinctx.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 whatengagement_sendsis 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 overfoundRows: true. The increment now comes last and the order of that SET list is load-bearing;eventRunnerSql.test.jsis what catches a reorder.Two things that had to change underneath.
setHealthis now escalation-only — health has always been a high-water mark here, and without a rank a retry after a stall would demotestalledback todegraded. And the catalog route serves triggers:/admin/engagement/triggersisadminOnlywhile a definition is authored byadminandeditor, 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, andeventsAdmin.test.jsdoes not stubeventPhaseGates.db— so the run-console test hung ten seconds against the dead-port pool and failed withECONNREFUSED, saying nothing whatever about the route it was testing. Phase 4's expansion leg did the same toeventRunner.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-existingengagementManifest.test.jsCRLF failure (confirmed by stashing this branch's changes and watching it fail unchanged;edgebefore: 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 ineventRunner.test.js, 5 ineventRunControls.test.js, 6 ineventSpec.test.js, and 7 ineventRunnerSql.test.jsagainst 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— anongate needing twouo.champ.boss_upfiringswhere location contains "Yew", then anafter: '10m'gate, then an ungated wind-down — authored, published and started asnavadmin, with firings sent through the realctx.events.emitseam:
- 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, and1h30mrefused 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 onlylocation, the one variable the condition names.spawnName,bossNameandspawnSerialwere in the payload and never touched the row.- Two matching firings advanced the phase, and the next phase opened its own
aftergate with adueAtten minutes out. The log reads: threecondition.evaluatedlines (0 of 2, 1 of 2, 2 of 2),phase.advanced,phase.completed,phase.gate,phase.entered.- A second run went
stalledwithEVENT_PHASE_STALL_MS=45000—degradedfirst from a failing announce, then escalated tostalledand 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_upwhere location contains "Yew" / seen so far 0 of 2 / since 9:56:58 PM (6 min) / last related event …".- §N2's split held:
navmodpressed 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:
validaterefused its own output. The normalised gate carriesdormant, 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'sactionVersionanddormant; the gate just had to follow it.validate(validate(x)) === validate(x)is now a test.- 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.advancedis now written by whoever made the decision, and the tick skipsforced.- A satisfied gate's clock kept running.
elapsedSecondsmeasured to read time, so the panel said 139s beside a loggedwaitedSecondsof 121. It now stops atsatisfied_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.
edgeinwebsiteanddocs. Two new tables —event_action_settings(the switchboard) andevent_run_budget(what a run has spent and the most it may) — plus two columns onevent_versions,verified_atandverified_by.GET/PUT /admin/events/actionsandPOST /admin/events/:id/verifyare the last routes this feature was missing barcleanup, andevents/authorize.jsis 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
inspectandchange, not betweennotifyandinspect. §K's sentence read literally would have shippedcore.wait— which isrisk: 'inspect'— disabled, so every published event that waits breaks on a fresh deployment until an admin finds the switch. Aninspectaction 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:changeandirreversibleare the steps only an admin may author.- The tightest cap wins.
event_action_settings.capsis per action whileevent_run_budgetis one row per dimension, so two actions spendinguo.creatureshave 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 todegraded. A cap breach or a disabled action is not an outage, so it isrefusedrather thanfailedand gets its own log kind — but it takes the same disposition a failure takes, which means achangestep's defaultpausestops 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
UPDATEand 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. Andspendis a parameter ofmayInvokerather 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.)
NULLcap 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.
registerEventActionsis not on the module-facing API yet — that seam is Phase 7's — and core's own three actions declare nocost. 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:
- The run console route dropped the budget.
runs.detail()returned it and the client read it, butgetRunbuilds 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.- 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-existingengagementManifest.test.jsCRLF failure (confirmed by stashing this branch and watching it fail unchanged;edgebefore: 1810/1746/63/1). +111 tests, of which the +10 skipped are the new SQL cases skipping without a database.eventAuthorize.test.js(27) ismayInvoke'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 ineventRunner.test.js, 19 ineventsAdmin.test.js, and 10 ineventRunnerSql.test.jsagainst 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.announceandcore.cueenabled, the rig'schangeaction disabled, and every row markedconfigured: 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 stepsrefused 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, healthdegraded, runcompletedunderon_failure: skip. Withpause, a disabled action stopped the run at step 0 and left the step after itpending.- The 403 walk held live as well as in the harness:
GETandPUT /actionsanswered 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 swaggerhere 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:
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.
edgeinwebsiteanddocs. MODULE_API 1.10.0, in both halves. Four names forwarded on the module-facingapi—registerEventActions,registerEventBudgets,registerEventLeases,registerEventOptionSources— one new route (GET /admin/events/catalog/options/:sourceId), and one rule made real: acost()naming a dimension no module declared is refused.The seam is narrower than the four names suggest, and that is the point.
registerEventActionshas existed since Phase 1 and has staged core's three actions on every boot since; what it never had was a way in, becauseloader.jsbuilds its ownapifacade 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 argumentregisterCore()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 onregisterAudiences. Not a field on the action that names one: a catalog has more than one consumer —uo.options.itemsis 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:
costis 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'slegparam 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
unitis 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.
registerEventBudgetssupplies the label and unit, while WHICH dimensions an action spends is still discovered by pricing its declared examples — a registry cannot answer that, becausecostis 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, pointsMODULES_DIRat 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,budgetMstimeout, non-object, missingok),retry: false,await: 'human',holdFor, the whole envelope including the idempotency key,verify: truewriting nothing, the four id spaces,onceon 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.setSourcesQUEUES its updater rather than running it, sostartedwas alwaysfalse, 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 bycurl, 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 auseRefnow.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-existingengagementManifest.test.jsCRLF failure (confirmed by stashing this branch and watching it fail unchanged;edgebefore: 1921/1847/73/1). +29 tests: 17 ineventModuleContract.test.js, 11 ineventActionRegistry.test.js(the three new shape checks and the id spaces), and 1 ineventsRoles.test.js— the new route joining the 403 walk.eventsAdmin,eventAuthorize,eventVerifyandeventRunnerall gained the budget declarations their cap tests now need, and the test dimensions were renamedx.*→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:manifestandnpm run swagger— one route added, none moved.check:modulesandcheck:hostsgreen.The live walk, on the local review stack, driven by a throwaway
rigmodule inwebsite/modules/— a real module directory, scanned by the real loader, deleted before commit.
coreApi: "^1.10.0"resolved andmodule-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'score.options.legs— which returneddiscordand module-uo'stowncrier, proving the per-request resolve.- The switchboard named the dimension.
Wisps summoned … counton 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 backrefused: asks for 3 of "rig.wisps"; 3 of 5 is already spent this run, healthdegraded.- 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.jsasserts equality against themainshaci/core-ref.jsonpins, 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.jsasserts equality against the pinnedmainsha; 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.
edgeinwebsiteanddocs. 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 andctx.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 onedgeit 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 isrisk: '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
refdoes not exist until the module answers, so there is nothing to write a row about — the row core writes beforehand iskind: '@step',ref= that key. On the answer the reported resources are insertedconfirmedand the placeholder is resolved; if the answer never comes it stands, and cleanup callsrevert({ idempotencyKey, resources: [] }). That is why §F'sreverttakes 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_stepsrows. The step-shaped version buys the console's per-step retry for free and costs a second retry counter besiderevert_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.reconcileis declared here and TRIGGERED BY THE MODULE, throughctx.events.reconcile(). Core has no concept of the game being up (§F), so it cannot decide when to ask; module-uo already watchesbootIdto 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
driftedandorphanedhold a target for ever, so one bad night would disable a lease permanently with no control able to clear it.driftedmeans somebody else has hold of the value and this run has let go;orphanedmeans it vanished. Neither is a claim, and both stay loud throughcleanup_statusand the console instead, which is what rule 2 actually asks for. Recorded as a dated amendment inEVENTS.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
statusALONE.TEAMS.md§2.5 had to be corrected on this exact shape: MariaDB refusesON DELETE SET NULLon a foreign key whose column is a base column of a stored generated column (error 1901), andstep_idmust 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
cancelanswers 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_statusstranded a lease outright, and then madeEVENT_REVERT_MAX_ATTEMPTSmean one attempt.Verified:
npm test— 2025 tests, 1935 pass, 89 skipped, 1 fail, that one still the pre-existingengagementManifest.test.jsCRLF failure in a file this branch does not touch (edgebefore: 1950/1876/73/1). +75 tests: 14 in the neweventLedger.test.js, 20 in the neweventCleanup.test.js, 16 ineventRunnerSql.test.js(which skip without a database — see below), 8 ineventRunner.test.js, 9 ineventRunControls.test.js, 5 ineventModuleContract.test.js, 2 ineventActionRegistry.test.jsand 1 ineventsRoles.test.js. Client: 362 pass; the client builds.npm run routes:manifestandnpm 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.jsgained 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 realschema.sqlwas also applied to a fresh database and to an existing one, becauseCREATE TABLE IF NOT EXISTSnext 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 poolutils/dbbuilds at require time fromDB_NAME— andutils/db's owndotenv.config()readsserver/.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 = DBbefore 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
ECONNREFUSEDagainst the dead port. One missing stub costeventsAdmin.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
rigmodule inwebsite/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.rigdeclaredcoreApi: "^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.leasereserves its own ledger row, so it never went through the ledger's dirty-marking, so a run holding only a lease keptcleanup_status = 'not_required'and the cleanup leg — which selected onpending— 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_ATTEMPTSmeant one attempt, not three. The first failing sweep moved the run toincomplete, which took it out of the leg's own scan for ever. Visible only asrevert_attemptssitting at 1 through half a minute of live ticks; the test that covered the bound asserted<= 3and 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_attemptswas a tidy way to take acleanup: falserun 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 runcompleted+incompletewith 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 producingdriftedwith the world left alone, and a later run still able to lease that target; andcancel { 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 aftertaskkilland 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-timereconcileAll()marked the otherorphaned— neverreverted— 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_APIunchanged 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 forrisk: 'notify', andon_failureis what happens AFTEREVENT_STEP_MAX_ATTEMPTSretries — there is no per-action lever meaning "do not retry me". The lever a module has is the failure envelope, souo.broadcastanswersretry: falseto everything. And that alone is not enough:dispatch.classify()answersretryfor a budget timeout unconditionally without asking the action, and the defaultbudgetMs(10s) is SHORTER thanuoLinkClient's own timeout (12s), so core's deadline fired first on every slow shard and the refusal was unreachable. All three actions declarebudgetMs: 15000. That is the phase's most portable finding and it is now a contract rule inMODULE_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'sbootIdinto the resource payload andreconcile()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.postships ALONGSIDE the existingtowncrierannounce 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 isreversible: 'ledger'; a budget dimensionuo.broadcastsbounds 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 recordsevent:<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 ofuo.broadcastsagainst 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 loggedorphaned 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.
- A WS reconnect would have orphaned every live resource. The backfill replays the last several
server.helloframes in order — this rig saw three, each with a differentbootId— 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 thenorphaned: 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.- The shard explains its refusals and the run log dropped the explanation. A 403 body reads
{"reason":"admin write plane disabled"},legErrorlooks fordata.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.- 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.
UNRESOLVEDineventRunResources.db.jsincludesorphaned, so the cleanup sweep selects an orphaned row, callsrevert()on it and records itreverted. That contradicts §L's own rule — "a resource the module no longer has becomesorphaned, neverreverted" — 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.jsonmoves to a websiteedgesha for the length of this workstream (org lead).registerEventActionsexists only from 1.10.0, so under the oldmainpinregister()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 tomain.
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
ceilingon the emit envelope; participants on the action's success envelope;core.results.publishas an ordinary step; a nullableannounce_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-firingceilingon the emit envelope, applied at the send-time G24 gate asmeet(declared, emitted). It only narrows; two incomparable ceilings refuse every rule rather than resolving to either, which issegments.js's own posture.events/announce.jspasses'staff'whenrun.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 inengagement/templateSeeds.jsand its rules inengagement/coreRules.jsunder a per-group one-shot settings key. Phase 10 uses core's mechanism, with a THIRD key — the ruleENGAGEMENT.mdPhase 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 onlyrun.failedis. §J is right and this is now built that way: six at ceilingauthenticated/ default audiencesubscribers, exactly wherenews.postsits, andrun.failedatadminon both halves because a failure names the deployment's own broken machinery.- The row is
ENGAGEMENT.md§8.5, not §8.6. §8.6 ismodule-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.changedis 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
urlvariable, and that is deliberate. There is no public event page until Phase 14 —App.jsxmounts nothing under/site/events— andnews.posthas already paid for this mistake once: itspostUrlexample 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.failedis the exception because/admin/events/runs/:runIdexists today. Done in 14a, aseventUrl, carrying?run=— and the six went to version 2.startsAtLabelis a presentational fragment computed at the emitter, which isENGAGEMENT.md§4.6.1 convention 1 rather than a shortcut.startsAtis adatetimeand 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.hour12is set explicitly: left to theen-GBlocale, midnight renders "00:00" while the schedule editor beside it writes "12:00 AM".Two traps found in the build, both silent.
affectedRowscannot tell an insert from an unchanged upsert. The connector sendsCLIENT_FOUND_ROWS, under which anON DUPLICATE KEY UPDATEthat 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 := 0thenUPDATE … 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.startedfired 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 carriedevent:1as 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, andevent.run.failedreached the administrator's inbox and nothing else — the player who had received both "starting" notices got no failure notice at all.
core.announce.postqueued a second job against a post that had already been announced: the event's job carriedrun_id,posts.announce_job_idstill pointed at the news job,findByPostIdstill returned the news job, and after the event's job rolled up todonethe post'sannounced_atstill 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 ranked340 → 120.5 → 120.5 → -15with the tie broken deterministically, identical on a second call, and a re-reported member updated its score and itsuser_idin place whilejoined_atandrank_atstayed 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.startedrule namedpush, because §8.5's row and this phase's own Ships line both do. On the rig everyevent.id offered only email and in-app on the preferences screen whilenews.postoffered push — becausenotificationChannelPrefs.cataloggrants the push channel only to registered STREAMS,publishToUsersjoinsnotification_subscriptions, and that table is only written for a channel a user could switch on. So the tickle went to nobody, every time, andpushChannel.deliverstill answeredok: true, 'tickle published'.event.run.startedis 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
descriptionis read by two audiences, and one of them is the recipient. It is the rule editor's catalog text — and, throughprojection.project'sintrofallback, the body of every unauthored render throughnotify.eventorinapp.event. Sorun.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_idis nullable andfindByPostIdfiltersrun_id IS NULL, so the post admin panel and its retry button are untouched;posts.announce_job_idis written only when the post has none, andannounced_atis 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.killedas a first-class kind. overlay.tomlprotocol 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_VERSIONbumped; the key carried; the new kind stored and served. module-uo: the key sent on every event-driven write,uo.broadcastmade retryable, and the new kind mapped for visibility and declared as a trigger.docs: a newdocs/link/v6.mdas the spec of record, plusINTEGRATION.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_VERSIONandoverlay.tomlboth 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.busyfor an in-flight repeat; a TTL-plus-cap with a loud eviction; and top damagers at astaffceiling. The last three are 11a's.What the phase bought immediately:
uo.broadcaststopped being un-retryable. Phase 9 shipped it answeringretry: falseto everything including a 503 from a restarting shard, with a comment naming the line that would change. This is that line. It now defers tosidecarFailure— 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
idmade 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. Butnews.addwithannounce: truemakes 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:BridgeNewsremoves 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
ChampionSpawnregisters aChampionSpawnRegionover 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 — andMobile.Regionhides 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-v1is stamped once under a settings guard, so a twenty-seventh entry would reach fresh installs and nothing else.champ-boss-killed-v1is 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, withHold/Completeas 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.mdfor 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.listreconcile, proved end to end against one verified live-readConfig.Getkey. 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 TeamsmemberKey, 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_VERSIONamended in place at 1.10.0. See../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.jsresolves a resource to the action of the step that made it, and for a lease that action iscore.lease— a CORE action, on a path a module cannot register anything on. So everyoverriderow came backunansweredfor 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.websitejoins the phase:core.leasegains areconcile()andregisterEventLeasesgains an optionalinForce(). Deliberately notread()plus a comparison — a changed value is DRIFT, which teardown must report so the row landsdrifted, 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.SkillCapas the one proven key; a scaffolding write verb to makedriftedreachable at all; leases memory-only, so a restart is a free restore; a separateBridge.EventsEnabledgate rather thanAdminWriteEnabled; map + point + radius for the area; presence-plus-weighted-kills for the score; the shard-side bounds and grace window; chunkingparticipation.snapshotas the thing that defers; and noMODULE_APIbump.The catalog is far shorter than §D expected, and the measurement is the finding. §D frames the 258
Config.Getcall sites as splitting into two patterns. Measured: of the 158 non-Bridge sites inScripts/, roughly eight are read live. The allowlist is not a curated subset of a large pool — it is nearly the whole of what exists. AndConfig.Sethas 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 provingdriftedneeded aconfigsetverb in the rig driver.The walk found a defect in 11a's shipped code, which is the argument for the ordering.
bridge.busycame back 200, not 425, the first time anything produced it:BridgeIdempotency.Busybuilt its frame withBegin("bridge.busy")and then appended a diagnostic.Str("kind", prior.Kind), so the object carried twokindfields and every JSON parser takes the last. The sidecar readparticipation.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. RenamedbusyKind.One resource in
module-uomust 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.Killdoes not save the world, so the first restart test reloaded an emptyParticipation.binand looked exactly like a persistence bug —savethenshutdownis 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 §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.Amountdoes not exist. The property isMaxCount, andMinDelay/MaxDelayareTimeSpanrather than numbers, so the wire carries seconds.SpawnerandXmlSpawnershare 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 sameEventSink.WorldSaveas 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. AndTreasuresOfTokunois excluded:IsActive()reads its ownDropErarather thanStatus, 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.
edgeinwebsiteanddocs. 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— andMODULE_API_VERSIONis 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/pricedispatches nothing and records nothing, takes the spec in the body because the plan being priced is unsaved between keystrokes, and isadmin, editorforverify'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 byengagement/conditions.jsand rendered into the diagnosis panel's sentence by the same labels.- Start now opens a dialog carrying
rehearsal,scopeandparams. 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 —
qon/catalog/options/:sourceId,searchableon the answer — and the only UI that reads a source never sent a term. The spawner list is 6,707 entries againstMAX_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)intoA and B and Care 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
falsefor every param nobody touched.- An empty field removes the key rather than posting
"".checkParamsreadsundefined,nulland''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.
coerceLiteralis borrowed from the engagement builder unchanged, and its rule matters here for the same reason: turning-intoNaNmid-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 neweventPrice.test.js, 13 tests, plus the 403 walk's new row); clientnpm test— 380 pass, 0 fail, 18 of them new; the client builds.routes:manifestandswaggerregenerated — 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.
Built as
Integration-kit#10+docs#226, red oncheckCoreApiby design. It is three chapters, not one, and the template gains code. The book taught a read-only data path end to end and never told anyone to build a command path, so chapters 3 and 4 gain one section each (§2a; "A command that changes the world runs at most once"), both skippable until you want chapter 5. The template ships one budget, one option source, one lease and one ledgering action plusserver/sidecarClient.js— named for the filenamenoGameConnection.test.jsalready anticipated, with a real timeout, a real key passthrough and a simulated transport in one replaceable function. Running those through core's REAL registry and dispatcher atedgeis what found the two defects §F now records; the prose found neither.
Phase 16 — Acceptance walk and cutover
The walk first, against released artefacts, not a working tree. The whole rig — ServUO, sidecar, website, emulator — running a real multi-phase event, including three deliberate failures:
- a mid-run process restart, proving the run resumes and no step double-executes;
- a sidecar kill mid-phase, proving the run degrades rather than failing and world writes park;
- a cap breach, proving
refusedand an author who is told why.
Then edge → main, in the order every previous cutover used: the protocol side first, the module,
core, docs, then the kit's re-pin and runicgateway.com.
Split into 16a (the walk) and 16b (the cutover) (org lead, 2026-09-09), on the same argument 12a/12b and 14a/14b were split on. The two sentences above cannot both hold:
link,servuo-pluginsandModule-uoall release on push tomain, so no released artefact carrying events can exist until after the cutover. Engagement Phase 13 met the same wall and resolved it the other way, cutting over first and walking frommain. Here the walk goes first against artefacts built fromedgeexactly the way a release builds them, because every walk in this workstream has found defects and a defect found onedgeis a reviewed PR rather than a hotfix tomain. 16b re-verifies against the real released bundle — install, boot, run one event — so the delivery path is still proved, just second. A third leg, 16c, carriesrunicgateway.comand.profile.16a WALKED, and it is four repos —
Module-uo,website,servuo-plugins,docs. The whole rig: real ServUO 57.4 (208k items, 42k mobiles) → acargo --releasesidecar on protocol 7 → core with the module installed from a release-shaped bundle → the Android app on an emulator. The overlay was deployed from a tarball built the way CI builds one, into a tree withScripts/Custom/ BridgeandSaves/Bridgedeleted first, so it was a first install rather than an upgrade.All three deliberate failures pass. (1) A mid-run process kill landed mid-TEARDOWN — sharper than mid-step, since a phase executes in about a second — with the run
completed, cleanuppending, a lease half-returned and 21 world objects up: teardown resumed on restart and all 15 steps still readattempts = 1, so nothing re-executed. (2) The sidecar killed during a phase gate left the rundegradedrather than failed,core.leaseretrying with a reason, and the four world writes behind it parked atattempts = 0; the shard reconnected on its own. (3) A cap of 5 against a step asking for 12 answeredrefused— its own status — withcode: "cap"and "asks for 12 ofuo.creatures; 0 of 5 is already spent this run", and the dry run had already refused it in the author's own words.Six defects, all in code already merged to
edge, and the suites were green on either side of every one. Two were blocking or worse. The spawn atlas could not import on a stock ServUO tree — a case-sensitive JS dedupe against an..._ai_ciPRIMARY KEY, four colliding decoration spellings in ServUO's own files, and the whole transaction lost; with no atlas every option source answers empty and no world verb can be authored at all. Teardown of all five world verbs was a no-op that reported success —revertOwnedsent the despawn under the step's key, which is the key the SPAWN used, so the shard replayed the spawn's reply andOnDespawnnever ran; the ledger readrevertedwhile the shard held all 21 objects, and the same despawn under a fresh key removed every one. Then: the public calendar served neither live nor recent runs though §I promises all three, so the site saidliveon one page and showed nothing on the other; a resource leftrevertingby a crash was never reclaimed, and the manual cleanup route answered 200 while doing nothing, which stranded a lease and blocked the NEXT run of the same event; a transient refusal under an idempotency key was permanent, because the shard's store had no case for a handler that ran and deliberately did nothing; and three facts every announcement computes were declared by no trigger and silently dropped.One reported defect was withdrawn:
skiprefusing afailedstep is not a dead end, becauseresumecarries a run past any settled step — the route's own docs say so and the rig confirmed it. The runner claims onlypendingsteps, sofailedandrefusedare both settled.Every fix is verified against the rig, not only against tests: the atlas imports 309 decor types and 6,455 points; a full four-phase run's teardown leaves the shard owning 0; a lease stranded by a real crash is reclaimed in one sweep and
cleanup_statusreachescomplete; the same save key 25 seconds apart answers "15 seconds ago" then "40 seconds ago"; and/site/eventsshows a live run as Happening now beside recent ones, in the browser and in the app. Each new test was confirmed to FAIL without its fix.
Module-uo'srevertno longer forwards core's key at all —MODULE_API.mdnow says why that key identifies a lost dispatch rather than addressing the undo. Protocol 7 is amended in place: a refusal releases its key, with the rule that pays for it written down — do not answer*.errorafter changing the world.
16b CUT OVER (2026-09-09/10) — six steps, and core before the module. The protocol pair (
link#40+servuo-plugins#26) is ONE step, not two:bundle.yml's Gate 1 reads the protocol number out of both released artefacts and refuses a pair that disagrees, so whichever lands first leaves a compose that cannot run. Then core (website#199), the module carrying its own re-pin (Module-uo#34), the app (Android-app#46), the kit's re-pin (Integration-kit#11), and docs.Core lands before the module, which departs from the sentence above and matches what the engagement cutover actually did:
Module-uo'sci/core-ref.jsonhas to name a websitemainsha carrying MODULE_API 1.10.0, and that sha does not exist until core has landed. Four decisions, all as recommended (org lead, 2026-09-09): that order; the app merges with nov*tag, so no APK was cut; the re-verify walks the whole delivery path; andedgestays standing in every repo rather than being deleted as the module-system cutover deleted its own.Releases cut: sidecar v2.2.0, overlay v1.2.0, bundle 2026.09.10 (protocol 7).
websitenever releases.MODULE_API_VERSIONandEVENTS.mdare untouched by this leg — the cutover moves no contract.
Integration-kit#10had been merged early, on 2026-09-08, though it was written to be held — so the kit'smainwas red oncheckCoreApifor two days. That is what step 5 closes, and it is the reason the re-pin is a repair rather than only a date.The outage, and what it did not break
Gitea was unreachable for about ten minutes in the middle of the window (Cloudflare 1033/530) and killed both release runs.
link's built every binary and wroteSHA256SUMS, then died pushing the tag:fatal: unable to access … The requested URL returned error: 530.servuo-plugins' died insideSet up jobafter 11m52s with no step ever executing — which is why that job's log route answers 500 while its predecessor's serves fine: there is no log blob, and that absence is evidence. No tag was pushed either time, so the orphaned-tag failure mode did not occur, and re-running both byworkflow_dispatchpublished them. The first to land left the pair mismatched and compose run 102 failed exactly as the PRs predicted; the second dispatched it again and 103 composed.link'srust-gatesreds on three earlier PRs werecurl: (6) Could not resolve host: sh.rustup.rsinside the runner — infrastructure, not code, on all four counts.A job's log IS readable on this instance, through the web route rather than the API:
/{owner}/{repo}/actions/runs/<n>/jobs/<j>/logswith an API token, served astext/plain; step statuses come from the UI's own POST endpoint with a_csrfcookie. Every earlier phase diagnosed CI by reproducing jobs locally, on the belief that logs were unreachable. They are not, and reading one is what turned four red X's into four known causes in about ten minutes.A seventh defect, red on every events PR since Phase 10
website'sserver-testsjob had been failing since#192— eight PRs, every one reporting# fail 1, always the same single test, so nothing else was ever hiding behind it. The workstream merged over it eight times.
events/announce.jsaskedIntl.DateTimeFormat('en-GB', { …, hour12: true }), and that is not the same request as a 12-hour clock. For a locale whose default cycle is h23 —en-GBis one — Node 20 resolveshour12: truetoh11, whose hours run 0–11, so midnight renders0:00 am; Node 22 and later resolve it toh12and it renders12:00 am. Same ICU (78.2) on both sides, so this is V8's ECMA-402 behaviour and not locale data — no amount of matching the runner's locale would have found it.The image ships
node:20-alpineand CI runs Node 20, while a dev machine is newer. So the mail every real recipient got said "0:00 am" beside a schedule editor saying "12:00 AM" — one instant, two spellings, the exact contradiction that option was added to prevent — and it rendered correctly in front of everyone who reviewed it. Fixed tohourCycle: 'h12'(website#200), which is the formrecurrence.jshad already adopted for the mirror-image case (h23rather thanhour12: false);announce.jswas the lasthour12in either repo.The rule:
hour12is a request about a locale's preference,hourCycleis a request about the clock. Ask for the clock. And the test now says so out loud, because it can only fail on Node 20: a green run on a dev machine is not evidence, and CI is what holds that line.The re-verify, from artefacts an operator would download
This is the leg 16a could not do — a locally built bundle cannot go through core's module installer, which is https-only with a host allowlist.
installer released v0.1.1binary, checksum matched against the release's ownSHA256SUMSbundle resolved 2026.09.10, protocol 7; both component checksums verified by the installer overlay sync a first install into a stock 57.4 tree — add=30 change=1 unchanged=0script build 0 Warning(s) 0 Error(s)— the released overlay compiles on a stock tree, which no release had ever been asked to proveshard boot [Bridge] enabled=True … adminWrite=True … events=True, thenconnected to 127.0.0.1:7788sidecar server.hellofor 208,568 items / 42,871 mobiles;x-uolink-version: 7event plane lease.list.ok(config and targeted property leases),item.catalog.okwith its bounds,GET /world/<run>an empty list rather than a 404core released mainon a throwaway database,capabilities: ["events"]on/public/versionone event published, run, completed/health: ok, results publishedthe page /site/eventsreads "Everything scheduled, live and recently finished" and lists a run that finished two minutes earlierThe last row is 16a's calendar fix holding on
main: before it, a run that had already started or finished was absent and the page renderedentries: [].A fresh
Bridge.cfgstill shipsEventsEnabled=falseandAdminWriteEnabled=false— the operator's real first-boot state, and the released config confirms it rather than a working tree's.One thing checked and deliberately not reported as a defect: a cancelled run appears on the public calendar. It is meant to. The entry carries its own
status, and the page renders a past cancelled run as "Did not happen" — the honest label, not a silent omission.The module's own install was walked too, once step 3 cut
Module-uov1.2.1. The module arrived the way an operator's would:POST /admin/modulesnaming the release's manifest (not its tarball — core answers a tarball with "the install manifest is larger than 262144 bytes", which is the size guard doing its job), core fetched the artifact over https from the allowlisted host, verified itssha256, and mounted it on the next boot with 12 event actions and 27 triggers. ThenPUT /admin/uo-link/configwith the four values the installer printed answeredstatus: connected,pluginConnected: true,protocol: 7— released core, released module, released sidecar, released overlay, all four talking.On that rig the two Phase 16a fixes were confirmed in the shipped artefacts rather than in a working tree: the atlas imported off a stock tree (309 decor types, 6,455 points, 800 creatures, 558 landmarks, 387 regions, 25 champions — the import that used to die at 313), and a world verb ran and tore down for real — three orcs spawned, ledger
reverted×3,cleanup: complete, and the shard itself answeringworld.owned → owned: [], pruned: 0. That last check is the one 16a's no-op teardown hid behind. The enablement gate and the cap behaved as specified on the way past: the dry run refused the action before it was enabled, then priced ituo.creatures 3 of 10.And the leg found two more defects, both in the released bundle and neither visible to any test (
Module-uo#35).The aggregator discarded the
UniqueId, so no Phase 12b property lease was authorable at all. All 6,455 spawn points imported withunique_idNULL;listSpawnersfiltersunique_id IS NOT NULL, souo.options.spawners— the only source those leases have — was an empty dropdown with nothing to explain itself. Every part of the path was right except one line: the files carry<UniqueId>,parsePointsreturns it, the column exists, the insert passes it.buildAtlasrebuilds each point from an explicit field list and the field was not on it.PARSER_VERSION = 4's own note says a point keeps itsUniqueIdand names Phase 12b as the reason — that bump exists to re-read trees for this field, and the field was dropped one function later. The intent shipped as a comment. Fixing it needsPARSER_VERSION5 as well, because the tree's hashes have not changed — only what is kept from them — so nothing would re-read an existing install.A landmark option value named 23 places at once. 558 landmarks, 320 distinct
facet/name:Trammel/Entranceis Blighted Grove, Covetous, Deceit, Despise, Destard and 18 more, andlandmarkPointresolves with.find(). So 22 of the 23 were unreachable and an author who picked "Entrance — Destard" got Blighted Grove, with a successful run and no warning. The group was already the disambiguator — shown in the dropdown, left out of the value. Nowfacet/group/name, distinct across all 558, with the two-part read kept as a fallback because a published version is immutable and those stored values are the authored record. A three-part value whose group is gone refuses rather than falling back: it asked for one place.Both are the same failure shape as 16a's blocking defect and worth naming as a class: an option source that answers empty, or answers with a value that does not identify one thing, disables a feature silently. Nothing errors, the form simply cannot express the thing — and a test that checks the parser, or the query, or the column in isolation passes throughout. The atlas fixture had no
<UniqueId>in it at all until this phase, which is why a green suite said nothing for two.
Two documents that are cutover-window work by construction.
runicgateway.com—checkFactsreadsmain, so any claim about events is unverifiable until the cutover lands. Same 12a/12b split the engagement workstream needed. 16b landed it, so both of these are now unblocked:maincarries the engine, the module and the app, and the bundle triple the site quotes is sidecar v2.2.0 / overlay v1.2.0 / bundle 2026.09.10..profile— the org landing page is updated when the shape of the project changes, which a new subsystem is.
16c BUILT (2026-09-09/10) — and it began by closing the cutover's missing seventh step.
runicgateway.com#30and.profile#6, both ontomain; the leg's first act wasdocs#232.The step 16b left on
edgeSix repositories were cut over and every one of them showed 0 commits on
edgethat are not onmain. This one showed 46. Step 6 (#231) landed the record onedgerather than cuttingedgeover — an easy thing to miss, because the step's own PR merged green and closed. The consequence was quiet and total:docsmainopenedEVENTS.mdwith "revision 5. No code written. Read against …MODULE_API_VERSION1.9.0 · sidecar protocol 5" while six repositories shipped the engine on protocol 7, andlink/v6.mdandv7.md— the specs of record for two protocol versions — existed on no default branch anywhere.Nothing in this workstream could have caught it. Every check that guards a contract lives in the repository that depends on the contract, and a documentation repository has no dependants. What found it was the one check that reads
docsfrom outside:runicgateway.com'scheckReference.mjsasserts every canonical document it names still exists onmain, and adding the current protocol spec failed with✗ canonical doc link/v7.md. The site is the docs repository's only dependant, and 16c is the only phase that would ever have run that check.The merge was clean, and both
PROJECT_TREE.mdfiles stayed onmain's newer automated syncs —edgenever edited them, so git keptmain's side.edgestays standing, per 16b's decision, now four generated commits behind.The site
The checks were red before the phase started and named their own answers, which is the whole bargain §12 of that repository's plan struck: nine
checkFactsvalues (protocol 5 → 7 in all three declaration sites,moduleApi→ 1.10.0, the bundle triple,linkv2.2.0,Module-uov1.2.2 — a third module release, one past the v1.2.1 the cutover cut), and twenty-sevenBridge.cfgkeys the site listed nowhere. Those became five groups rather than an appendix, becauseEventsEnabledis a second consent switch and belongs beside the ceilings it governs rather than filed underAdminWriteEnabled.Two pages, the treatment Teams has: Scheduled events under Administration and Events architecture. Two capability entries, so the homepage,
/features/and/modules/stop omitting the subsystem — and the calendar one is deliberately notneedsModule, because a bare core can author and run an event and that marker means "present, correct and permanently empty".
/privacyowed a row and had none.event_run_participantsis personal data — scores and ranks against a module-opaque member key, linked to an account where one is linked, feeding a participant's own history. The newdeploy-eventsrow states the retention exactly, including the asymmetry that matters: the diagnostic log is swept after 90 days and only on terminal runs, while the run, its steps and its participants are never swept, because they are the record of what was done to a shared world.A naming collision worth fixing while it was cheap.
reference/event-catalogis about what a shard emits; with a scheduled-event system shipped, two things in the documentation were called an event catalog. Retitled "Shard event catalog", with the route left alone so nothing outside that repository breaks — and the page now opens by saying which of the two it is, since the kinds it lists are exactly what a phase can wait for.No screenshots, and stated as a choice: capturing the events surfaces means standing the whole rig back up for images no check requires, and the engagement workstream's site leg added none either.
.profileOne bullet, and two stale numbers. The bullet says the posture rather than the feature list — off by default, caps in the database, cleanup generated from a ledger, and an event does not edit the world, it holds a lease the game restores on its own deadline. The numbers are protocol 5 → 7 in the four values the installer prints (the block a reader copies into Admin → Shard, where a wrong number is a pairing failure with no obvious cause) and module-uo v1.1.0 → v1.2.2.
What 16c did not need
No
EVENTS.mdchange, noMODULE_API_VERSIONchange, no protocol change, no release. The phase moves no contract — it makes the ones already moved legible from outside the organisation.
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
mayInvokekeeps 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/[addstay excluded.
Dependency summary
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.