# The Event System — investigation and architecture
**Status:** Investigation and architecture proposal, revision 5. **No code written.**
**Read against:** the working trees on 2026-09-01 · `MODULE_API_VERSION` 1.9.0 · sidecar protocol 5.
**Companion to** [`MODULE_API.md`](MODULE_API.md) (normative module contract),
[`MODULE_SYSTEM.md`](MODULE_SYSTEM.md), [`ENGAGEMENT.md`](ENGAGEMENT.md) and
[`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) (the write plane and its exclusions).
A game-agnostic engine for scheduled, bounded, audited changes to a live game world — driven by the
staff who already run the site, and built so core never learns a game word.
> **Revision history.** Rev 1 was the first survey. Rev 2 adopted the UO Event Moderator / Player
> Event Coordinator programs as a model, cut points, and treated `ADMIN_CONTROLS.md` §8 as superseded.
> Rev 3 corrected rev 2 on mutation (it is practical, as a *lease*) and brought item rewards back.
> Rev 4 deleted rev 3's `registerEventRewards` registry, which carried four UO nouns in a core
> signature — a reward is an ordinary action. **Rev 5 removes the delegation model entirely:** the UO
> programs stay as inspiration for the capability set and the quotas, but permissions gate on the
> **existing admin roles** — no new role, no grant table, no proposal queue.
---
## Verdict
**1. This is a strong fit, and the differentiating feature is not the spawning.**
It is the tooling around it, which does not exist anywhere. Broadsword runs roughly twenty-five
shards' worth of Event Moderator programming on forum posts and a WordPress calendar plugin — with no
series field, no recurrence, no participation tracking, no results publication and no audit trail.
Every one of those is something this platform is already shaped to do well and a game client
structurally cannot do at all. The site→game direction this needs is modest: gates, props, NPCs,
named creatures, announcements.
**2. The generic half is nearly all already built.**
The staged-registration registry, the seven-value ceiling lattice, the closed condition grammar over
module-declared typed variables, the compare-and-set outbox claim, the retry/classify leg dispatcher,
the schema-driven admin form builder, and the spawn atlas that already knows every region, creature
and altar on the shard — offline, with the shard down. The genuinely new core pieces are a scheduler,
a step runner, a per-action cap, a resource ledger, and one new module registration.
**3. One decision is superseded rather than overturned, and one of its exclusions survives.**
`ADMIN_CONTROLS.md` §8 excluded world writes on 2026-07-12, when the bridge was a read plane with
four moderation verbs attached. That reasoning was correct for that platform and does not bind a
platform whose stated purpose is two-directional. What survives the reopening is **no arbitrary
`[set` / `[get` / `[add`** — the one exclusion that cannot be expressed as a bounded, recorded
action. Every world change is either an **event-owned object** that is deleted at teardown or a
**leased override** that restores itself; item rewards are one more action an admin switches on, or
does not.
---
## The model: what UO already runs
Two live programs, read from uo.com. **Inspiration, not mechanism** — they are here for what they
prove about the capability set, the quotas and the event slate, and explicitly *not* as a delegation
model to build. Permissions gate on the admin roles this platform already has.
The ladder below is context for *why the quotas are where they are*. Runic Gateway implements none of
it: an event is authored and run by site staff, gated on `requireRole` like every other admin
capability.
| Tier | Who holds it | Powers | Oversight |
| --- | --- | --- | --- |
| **Developer Event** | the operators | unbounded | none required |
| **EM Event** | an appointed Event Moderator, **one per shard** across ~25 shards | standing and broad — storyline arcs, recurring series, custom encounters, **and event items** | program rules; published to the official calendar |
| **Player Event (PEC)** | any player, approved per event | tightly quota'd — see below | two weeks' notice, an in-game review meeting, per-event approval |
| **Worldwide Event** | the operators | one definition, every shard at once | — |
### The PEC quotas, verbatim from the program page
These matter because they are the safety model, and they are numeric rather than role-shaped.
- **Monsters** — common creatures (orcs, skeletons, zombies, brigands) with custom names and hues;
regular mobs may be enhanced into "simple" boss variants. **Capped at 30 total monsters, or small
groups of 2–4 boss-like mobs.**
- **NPCs** — up to **5** oracle NPCs, dialogue limited to **5 lines each** (3 if keywords or items
are involved).
- **Gates** — temporary, **up to 4 hours** per event, approved case by case.
- **Decorations** — temporary lockdowns of decorative items, including non-craftable pieces.
Permanent decoration prohibited.
- **Never** — *"The PEC program NEVER offers drops of any kind for any reason."* No item placement on
monsters. No dungeon bosses. No cross-facet gating to restricted areas. **Spawners unavailable.**
- **The drop prohibition is a tier boundary, not a system-wide policy.** It binds the *player* tier;
EM events are where UO's own event items come from. So an engine has no business forbidding rewards
outright — here that is an admin switch on one action ([§K](#k--security-model)), not a rule.
### Five findings this produces
**1. The tiers tell you what the powers are worth, not who should hold them here.**
Read as a capability list they are excellent: named creatures, boss variants, oracle NPCs, gates,
temporary decoration, and — at the upper tier — items. Read as an authorisation model they are a
program for volunteers UO does not otherwise trust, which is not the situation on a self-hosted shard
where the person authoring the event is the person who owns it. **Permissions gate on the existing
admin roles.** No new role, no grant table, no proposal queue.
**2. Budgets bound blast radius; roles bound identity. Budgets are the better control.**
Thirty monsters, four bosses, five NPCs, five lines, four hours. Core holds
`{ dimension, consumed, cap }` per run; a module declares what a dimension is called and what each
action costs. Rust's dimensions would be prefabs-placed and zone-minutes, and core never learns
either word.
**3. Quotas survive the simplification, and their reason changes.**
Without delegation a cap is not a trust boundary — it is a guardrail against the two things that
actually go wrong on an admin-only system: a typo (30 became 3000) and a compromised session. Both
are real, both are unbounded without a cap, and neither is addressed by a role check. So caps stay,
as a **per-action deployment setting an admin edits**, not as a property of a grant.
**4. Spawn sets, not spawners.**
PEC withholds spawners deliberately. A running spawner is unbounded over *time*, which is exactly
what a quota cannot express. The right primitive is a bounded one-shot spawn of N creatures, each
serial recorded in the ledger and deleted by serial at cleanup — bounded *and* cleanable.
**5. Everything above is a special case of one idea: an event holds things temporarily.**
A spawned creature is an object the event owns until teardown; a gate is the same with a shorter
fuse; a changed rate is a *value* the event owns until teardown. Generalised, an event never edits
the world — it **holds a lease**, and baseline is what is true when no lease is held.
### What the real calendar shows, and what it is missing
September 2026's EM slate: Fishing Contest (Drachenfels), Royal Spy Mission (Great Lakes, roughly
weekly), Harvest Party, Governor's Meeting, Council Meeting, Royal Britannian Guard, LUDO World
Championship Final (Test Center 1), Bloodmother, Mini Event, Shrouded Deliveries, Sunsets and
Snowcaps.
**Gatherings, competitions and civic roleplay outnumber monster encounters substantially.** What most
of that slate needs is a venue, a gate, a few props and NPCs, an announcement, participation tracking
and a published result — not a spawn engine. That is the single most important input to scoping this
feature, and it is why phases 1–7 of [§M](#m--phased-implementation-plan) deliver most of the value
without touching a world write.
Each listing carries a title, shard, date, start time, a **local timezone** (KST, JST, PDT, CDT,
CEST — per shard, never UTC), an in-game meeting location, storyline prose and organiser attribution.
Three things it does *not* carry are the gaps worth building into:
- **No series or recurrence field.** A monthly fishing contest and a weekly Royal Spy mission are
each hand-entered as standalone occurrences.
- **No arc.** "Royal Spy Mission" → "Royal Spy | Risky Partner" → "Message From the Void" is
continuity that exists only in prose.
- **No results, no participation, no audit.** What happened at an event is not data anywhere.
---
## Leases: the primitive underneath everything
A correction to an earlier revision, which ruled out mutating existing state as impractical. It is
practical, the framing that makes it so is not "change and change back", and it generalises across
games better than spawning does.
> Modify an existing shard spawner or creature — *not practical.* Mutating shard-owned content means
> the ledger has to hold a *before* image of somebody else's object and restore it correctly after a
> crash, a save, a staff edit or a restart.
>
> — revision 2, §G
Three of those four hazards are not hazards. The before-image lives in the **website's** database, so
it survives a shard restart intact; a world save simply persists whatever is currently true and
restoration writes the baseline back over it; a deleted target makes restore a successful no-op. Only
one is real — **somebody else changing the same property while the event holds it** — and that is an
ordinary lost-update problem this codebase already knows how to solve.
### An event does not edit the world. It holds a lease.
Baseline is what is true when no lease is held. That reframing is not cosmetic — four properties fall
out of it that "edit, then edit back" does not give you:
| Property | Why the lease framing produces it |
| --- | --- |
| **Temporary by construction** | There is no state in which the world is permanently changed and somebody forgot the undo. The undo is the default; holding is the exception. |
| **Conflict-detectable** | Two events cannot hold a lease on one target. The ledger answers this with a unique index over `(owner_module, kind, ref)` among non-reverted rows, so the second event's step comes back `refused` at authoring time rather than corrupting a baseline at runtime. |
| **Reconcilable, not replayable** | On reconnect the module asks "which leases should be in force right now?" and the game side makes reality match. Replaying a log of edits after an outage cannot produce a correct world; declaring the intended state can. |
| **No new table** | A lease is a ledger row: `kind: 'override'`, `payload: { target, baseline, applied, until }`. It slots into [§D](#d--data-model) unchanged, and `reversible: 'override'` joins the closed set beside `none \| self \| ledger`. |
### The two mechanisms that make it safe
Both are the whole of the original objection.
**1. Restore is compare-and-set, never a blind write.** Before writing the baseline back, verify the
current value still equals what the event applied. If it does not — a GM changed the same property
in-client during the event — do **not** restore. Mark the resource `drifted`, leave the world alone,
and surface it beside the unreverted resources on the run console. Blindly restoring would silently
revert a staff member's deliberate change, which is the one failure that would make operators
distrust the whole feature.
**2. The expiry lives on the game side, not only in core.** A lease ships with its own deadline and
the plugin restores baseline when it passes, **whether or not the website is ever heard from again**.
Core drives normal restoration; the plugin's deadline is the backstop. This inverts the naive design,
where restoration depends on core dispatching a cleanup step — which fails *open* if core dies
mid-event. A lease fails *safe*, and that difference is what makes unattended, scheduled world
changes defensible at all.
### What ServUO actually permits — and the trap in it
`Server/Config.cs` is a real runtime key-value store with typed `Get` / `Set` / `Save` / `Load`, so
configuration leases are genuinely feasible. But there are **258 `Config.Get` call sites** in
`Scripts/` and they split into two patterns that behave completely differently:
```csharp
// Cached at type initialisation. A lease on this key silently does NOTHING.
private static readonly int MaxAccountsPerIP = Config.Get("Accounts.AccountsPerIp", 1);
// Read live at the call site. A lease on this key takes effect immediately.
if (Config.Get("Staff.Staffbody", true))
```
So the UO lease catalog is a **curated allowlist the plugin ships**, of keys verified to be
live-read — never "any config key". A module must not advertise a lease it cannot actually deliver,
because *"the setting applied and nothing happened"* is the worst failure this feature has. That is a
testable obligation, and the test is mechanical: for each key in the catalog, apply, observe, restore.
> **Measured in Phase 11b, and it is far more lopsided than "two patterns" suggests.** Of the 158
> non-Bridge `Config.Get` call sites in `Scripts/` on ServUO 57.4, roughly **eight** are read live.
> The rest are cached at type initialisation. So the allowlist is not a curated subset of a large
> pool — it is nearly the whole of what is available, and the catalog Phase 12 inherits will be
> short for reasons no amount of care can change.
>
> A second measurement, which decides how `drifted` gets tested at all: **`Config.Set` has exactly
> one caller in the whole tree** (`Server/ScriptCompiler.cs`). No in-game command, gump or console
> verb writes a config key, so on a stock shard a GM cannot drift a *configuration* lease even
> deliberately. The compare-and-set is still required — Phase 12's object-property leases are
> trivially driftable — but the config half of it is proved with scaffolding, not by a GM.
Beyond configuration the same pattern covers any per-object property whose current value is readable
before it is written — an existing spawner's `Amount` / `MinDelay` / `MaxDelay`, a named creature's
stats, a `SeasonalEventSystem` entry's status. Loot stays excluded for the reason it always was: it
is per-creature-type and shard-wide, so it cannot be scoped to one run at all.
---
## A — Current architecture: what can be reused
Every row below was read in the working tree, not inferred from documentation. Nothing here needs to
be invented.
Four workstreams — modules, Teams, engagement, retention — built exactly the machinery an event
engine needs, and built it generically each time because a module had to reach it.
### Registration and contract
| Mechanism | Where | What it gives Events |
| --- | --- | --- |
| `registries.js` — stage / validate / commit | `server/src/modules/registries.js`, 1,277 lines | A module registers a batch, shape checked at the call, collisions at commit, and a module that throws leaves *nothing* behind. Any new registry must follow it verbatim. |
| `registerEventTriggers` | MODULE_API §2.4 · 26 in module-uo | Typed, versioned, example-bearing declarations over a **closed six-type set**. Already the schema language for action parameters — specified, validated, and rendered by a working admin form. |
| `registerTeamProvider` | MODULE_API §2.4 | The only precedent where core calls a module and waits: an envelope with no shape a failure can read as success, a 10-second budget, explicit fail-closed on the one visibility question. The action contract is this, inverted ([§F](#f--the-module-contract)). |
| `registerAnnounceLeg` + `announce_job_legs` | `utils/announceWorker.js` | `dispatch → classify → done \| retry \| terminal`, per-leg backoff, one leg's failure never touching another, status rolling up over rows rather than columns. |
| `ceilings.js` | `modules/ceilings.js` | Seven audience values ordered by containment, with composition that narrows. Gives "who may this event reach" and — for free — rehearsal mode ([§I](#i--admin-ui-authoring-and-the-calendar)). |
| `installed_modules` state machine | core schema + `modules/lifecycle.js` | A `state` ENUM held *separately* from `failure_stage` / `failure_reason`. That split is the right shape for a run that is running *and* degraded. |
### Execution, concurrency and persistence
| Mechanism | Where | What it gives Events |
| --- | --- | --- |
| Compare-and-set claim | `engagementOutbox.db.claim()` | Multi-instance-safe row claiming, no open transaction, no MariaDB version floor. Chosen by the org lead over `SKIP LOCKED` on 2026-08-29. The step queue should use it unchanged. |
| `settings.claim()` — `INSERT IGNORE` | `engagement/moduleSeeds.js` | The atomic guard that fixed the double-seed race the **engagement** workstream's Phase 13 hit (52 rules where the module ships 26). Also answers "only one instance may materialise this occurrence". |
| Poller workers | seven, all `setInterval` + `unref()` + `stop()` | The house pattern. An event runner is the eighth and looks like the other seven. |
| JSON columns with registry-driven validators | `engagement_rules.conditions`, `pages.blocks`, `engagement_templates.blocks` | The project stores authored trees as JSON validated against a registry rather than normalising them. Phases belong in a version snapshot, not in four more tables. |
| `engagement/conditions.js` | 251 lines | A **closed predicate grammar** — 12 operators typed against the declaration, `and`/`or`/`not` bounded at depth 5, validated at *save* with the offending variable named. Directly reusable as the phase-advance language, and its operator labels are already written for humans. |
| `activity_log` + `ctx.activity.log` | core | Admin audit for every definition edit, publish and manual transition, already reachable from a module. |
### Surfaces and content
| Mechanism | Where | What it gives Events |
| --- | --- | --- |
| The engagement pipeline end to end | triggers → rules → segments → cooldowns → outbox → email · in-app · push | Every announcement, reminder and result notice, with Events owning **none** of the delivery. Events emits a trigger; an operator decides who hears about it. |
| Schema-driven admin forms | `EngagementRules.jsx`, 716 lines | Core rendering a form it does not understand, from a schema a module declared. This is the answer to "no hard-coded UO assumptions in the UI". |
| The client module registry | `client/src/modules/registry.js` · `window.__rg` · the 9-member UI kit · `declareModuleSlot` | A prebuilt module chunk can register admin routes, nav rows, extension fills and slots on its own pages for core to fill. Both directions of UI composition exist. |
| **The spawn atlas** | module-uo: `shard_regions`, `shard_spawn_creatures`, `shard_landmarks`, `shard_champion_spawns` | **The authoring vocabulary, free and offline.** Re-derived from the shard's own ServUO tree every boot, fully populated while the shard is down. An event form can offer real region, creature and landmark dropdowns — including the "meeting location" field every EM listing carries — with no shard round-trip. |
| SSE with per-viewer projection | module-uo `shardBroadcast.js` + `shardVisibility.js` | A live channel that resolves a viewer's rung once at subscribe and field-projects every frame. Note it is **module-owned**, so core cannot assume it (§B4). |
| The write plane that already exists | sidecar `/admin/broadcast`, `/towncrier`, `/news`, `/pages/…` | Broadcasts, crier lines and full in-game news articles today, behind the shard's own `AdminWriteEnabled` switch and an Owner-protection floor, `actor`-attributed and audited both directions. |
> **Unchanged by anything here.** The three-repo data path, the sidecar's durable SQLite store, the
> shard-dials-out invariant, the bounded drop-oldest queue on the game side, and the module system's
> zero-internal-imports rule are all load-bearing and stay exactly as they are. This design adds a
> consumer to them; it renegotiates none of them.
---
## B — Existing gaps
What is genuinely not there, stated as what the codebase says rather than as assumption.
| # | Gap | Evidence |
| --- | --- | --- |
| 1 | **No scheduler in the website process.** | `node-cron` appears only in `bot/package.json`, and the bot container has no `modules` volume and cannot load a line of module code. The seven core workers are fixed-interval sweeps with no calendar of any kind. |
| 2 | **`kind: 'scheduled'` triggers are declared with no evaluator.** | ENGAGEMENT §7.1 Q6: *"declare now, build after Phase 9 — no evaluator yet."* `engagementEmit.js:161` actively refuses a direct emit of one. |
| 3 | **No per-capability switch or cap.** | `users.role` is `ENUM('admin','editor','moderator','player')` and every privileged route is a `requireRole()` call — which is the right gate and is *all* there is. There is no way for an operator to say "this action is available on this deployment, up to this much", which is what a world-changing action needs on top of a role. |
| 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. |
| 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. |
| 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. |
| 7 | ~~**The command path is not durable and has no idempotency.**~~ **Closed by protocol 6** (Phase 11a). `rpc.rs` is unchanged and still an in-memory pending map with a flat timeout — the replay was never the sidecar's to do. A command may now carry an `idempotencyKey`, and the SHARD executes a key at most once, answering a repeat with the original reply restamped for the retry's correlation id. Which is the only place the promise can live: the shard is where the world write happens. See [`../link/v6.md`](../link/v6.md) §2.1. |
| 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. |
| 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. |
| 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. |
| 11 | **No record of what the website changed in the game.** | The `admin.audit` stream records that an action happened and nothing about what it produced, so nothing can undo it. This is the piece cleanup depends on entirely. |
| 12 | **ServUO's own event system is not an authoring substrate.** | `SeasonalEventSystem.cs` is a fixed `enum EventType` of nine built-in content packs with an `Inactive/Active/Seasonal` status. It toggles shipped content. Useful as one cheap capability; not a foundation. |
### Documentation that disagrees with the code
- `docs/website/ARCHITECTURE.md` places the SSE fan-out inside the core backend. It is entirely
module-uo's, and has been since the module-system Phase 3 cutover.
- `docs/modules/rust-dryrun.md` finding 4 states the Android app feature-detects against
`GET /api/v1/public/modules`. It does not — `ShardStreamClient.kt` hardcodes
`api/v1/public/shard/stream`, a module path, and no Kotlin source mentions `capabilities`. The app
is UO-coupled today ([§J](#j--integration-map)).
- `docs/website/ENGAGEMENT.md` §8.6 already carries a row for this system — *"Come back for X — a
scheduled event is starting · operator-authored · ❌ needs a manual/scheduled trigger type"*. It
should be resolved by this work rather than left standing.
---
## B2 — What "event" already means here
The word is already carrying three loads. The new one is a fourth, and the collision is manageable by
qualifier rather than by invention.
| Sense | Where it lives | What it is |
| --- | --- | --- |
| **Game telemetry** — an "event kind" | the wire, `shard_events`, `shardIngest.js` | ~60 kinds the shard emits. Append-only, content-fingerprint deduped, fanned out over SSE. |
| **Engagement triggers** | `registerEventTriggers`, `engagement_rules.trigger_id` | "Something happened someone might want telling about", with a typed payload and an audience ceiling. Shares **one namespace** with push stream ids. |
| **ServUO's own** | `EventSink`, `SeasonalEventSystem` | In-game hooks, and a fixed nine-value toggle for shipped seasonal content. |
| **New — the authored occasion** | this proposal | A staff-authored, versioned, scheduled, multi-phase production that the system runs, watches and tears down. |
**Recommendation.** Keep "Event" as the human word — it is what a shard admin calls the thing, and
inventing a word for the UI would be worse than the ambiguity. Disambiguate everywhere else by
qualifier, exactly as `shard_events` and `trigger_id` already coexist: tables are `event_definitions`
/ `event_runs` / `event_run_steps`, and the runtime's own domain events are core-owned engagement
triggers under `event.` — `event.run.scheduled`, `event.run.started`, `event.phase.changed`,
`event.run.completed`, `event.run.cancelled`, `event.run.failed`. That prefix is legal under
`EVENT_ID` and unclaimed by any of the five core or twenty-six module trigger ids.
The one genuine conflation risk is in the module contract, where `registerEventTriggers` (declare a
game event) sits beside `registerEventActions` (declare a verb). Different id spaces, different
objects; the docs must say so in one sentence at each registration — the treatment audiences already
get.
---
## C — Proposed architecture
Core owns the engine; the module owns the meaning. What the codebase adds to that rule is *how* the
split is expressed — declaration plus dispatch, never a string core interprets.
**The seam, in one sentence:** a module declares typed verbs, says what each costs, and acts when core
asks; core owns whether the action is enabled, when it runs, in what order, how many times, within
what cap, what it created, and who is told.
```mermaid
flowchart LR
subgraph core["CORE — game-agnostic"]
direction TB
cal["Calendar & series
schedule · recurrence · arcs"]
auth["Authoring
definitions · versions · phases · steps"]
enab["Enablement & caps
per-action switch · per-run caps"]
run["Runtime
scheduler · steps · claims · retries"]
ledg["Resource ledger
what this run created or leased"]
logs["Run log & audit
why didn't phase 3 start?"]
end
subgraph mod["INSTALLED GAME MODULE"]
direction TB
m["module-uo — declares verbs, their costs
and their budget dimensions; translates each
into a sidecar command; names its own
resources for the ledger
module-rust answers the same calls with
Oxide commands and its own verbs"]
end
side["Sidecar
durable store · versioned wire · dumb forwarder"]
game["Game server
dials out · never listens · owns the world"]
subgraph eng["EXISTING PIPELINE — unchanged"]
e["event.run.started → rules → segments → cooldowns
→ outbox → email · in-app · push · announce legs"]
end
core -->|"perform()"| mod
mod --> side
side --> game
game -.->|"game events advance phases"| core
core --> eng
```
Core never holds a game connection, never names a game noun, and never interprets an action id or a
budget dimension. The module never decides whether an action is permitted, when it runs, how often,
or within what cap — and never owns the record of what it created.
### Five decisions that follow from the code and the two programs
**Phases and actions are configuration inside a version snapshot, not tables.**
The project stores authored trees as JSON validated against a registry, and it has *no migration
system*, so every table is a permanent commitment. What gets a table is what must be queried, claimed
or joined: runs, steps, resources, participants, log.
**The step is the unit of execution, and it is a row.**
One action invocation with a `due_at`, a `status`, an `attempts` counter and a claim — the shape
`engagement_outbox` already proved. Retries, timeouts, duplicate execution and resumption are all
properties of that row.
**The condition language is the one that already exists.**
A phase advances on `{ after: '30m' }` or `{ on: '', where: , count: n }`. The
trigger catalog a module already ships *is* the catalog of things that can advance a phase, so a
module gains phase conditions by adding one entry to an array it already declares.
> **Built in Phase 5, and `engagement/conditions.js` is reused unchanged** — its grammar, its type
> checking against the declaration, its depth and list bounds, and its operator labels. What Phase 5
> added is where the words are *read*: the same labels that fill the rule editor's dropdowns render
> the diagnosis panel, so `gte` says *"is at least"* in both places because there is only one place
> it is written down.
**A cap is checked before dispatch, and a breach is a refusal, not a failure.**
A step that would exceed a cap does not run and does not retry. It is recorded `refused` with the
dimension and the numbers, and shown to the author — because "you asked for 40 monsters and this
deployment allows 30" is an authoring error, not an outage.
**Cleanup is generated, never authored.**
An operator cannot be relied on to write the undo, and an aborted run never reaches the phase they
wrote it in. Cleanup steps are derived from the ledger at teardown and run on *every* terminal path —
completion, cancellation and abort alike.
---
## D — Data model
Eleven core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
tables carry no module prefix. (The count said "nine" over a list of ten from the revision that added
`event_series` until Phase 5 counted them.)
| Table | Holds | Why a table |
| --- | --- | --- |
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, `listed` (Phase 14a), created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. **`listed` is announcement, not permission** — see below. |
| `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. |
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`, and (Phase 6) `verified_at` / `verified_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. The two `verified_*` columns are the exception that proves the immutability: they describe something that happened **to** the version afterwards — a review — rather than changing the plan reviewed, which is also why a pass recorded against one stays true for ever ([§K](#k--security-model)). |
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
| `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. |
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. |
| `event_run_resources` | `run_id`, `step_id` **`SET NULL`**, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among the rows core still believes are ITS — see the amendment below | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. `@step` is a reserved `kind` core owns (rule 1, below); a module reporting one is refused. |
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. |
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
> **`listed` is Phase 14a's one addition to this table**, and it is deliberately not a second answer
> to what `state` answers. `state` says whether an event is SCHEDULABLE; `listed` says whether it is
> ANNOUNCED. The two came apart the moment there was a public surface at all, because publishing is
> what makes a definition runnable — so without the column a surprise invasion would have to be
> advertised a fortnight in advance in order to be allowed to happen. It defaults to 1, so every
> definition keeps the behaviour it had while the only reader was staff, and unlisting is the
> deliberate act. It hides the definition, its runs and its projections from the public surfaces and
> from a participant's own history; it hides nothing from staff.
>
> **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it
> follows from "editing a draft is free; no version exists yet" below. A draft's working spec has to
> live somewhere, and it cannot be an `event_versions` row: that table is immutable and a run pins
> one, so a mutable unpublished row in it would be precisely what versioning exists to prevent.
> Publishing copies the column into a version and leaves it standing as the next draft. `series_order`
> is the same kind of addition — `event_series.ordering` places a series among the others, and a
> definition's place *within* its arc is the column an editor drags.
### Not tables, deliberately
- **Phases** — configuration in `event_versions.spec`, materialised as steps when a run starts. A
phase has no identity a query needs; a step does.
- **Actions, budget dimensions and conditions** — registry entries declared by a module at
`register()`, like streams and audiences. Never stored; a stored one would outlive the module that
can perform it.
- **A reward catalog** — not a table and not a registry either. A reward is an ordinary action
([§F](#f--the-module-contract)), so a granted reward is a ledger row like any other, carrying an
optional `memberKey` so results can name who received it. What can be granted is an option source
the module answers; for module-uo its labels come from `shard_clilocs`, already imported for the
market pages.
- **Points** — still out. No core points system exists and no write path to any loyalty board exists.
Items are a different thing and they are in ([§G](#g--uo-implementation-plan-the-gap-list)).
- **A temporary leaderboard entity** — `event_run_participants` ordered by `score` *is* the
leaderboard.
---
## E — Runtime model
### Lifecycle
| Column | Values | Notes |
| --- | --- | --- |
| `event_definitions.state` | `draft` · `ready` · `archived` | Editorial, not a workflow. `ready` means a version has been published and the schedule is live; `archived` keeps a definition's history without it ever running again. |
| `event_runs.status` | `scheduled` · `starting` · `running` · `paused` · `ending` · `completed` · `cancelled` · `failed` · `missed` | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal for a schedule that passed its grace window while the process was down — **never a late silent start**. |
| `event_runs.health` | `ok` · `degraded` · `stalled` | Separate from status, because a run can be genuinely *running and degraded* — announcements landing, world writes parked — and one column cannot say both. This is `installed_modules`' split. |
| `event_runs.cleanup_status` | `not_required` · `pending` · `complete` · `incomplete` | Also separate: a run **reaches `completed` with `cleanup_status = 'incomplete'`** rather than being held open, and stays on the admin screen until a human resolves it. |
| `event_run_steps.status` | `pending` · `running` · `done` · `failed` · `skipped` · `refused` · `cancelled` | `refused` is a cap breach **or a disabled action**, and it is deliberately not `failed` — nothing is wrong with the system, the deployment simply does not permit what the author asked for. It takes the same **disposition** a failure takes (`on_failure`, and health `degraded`), and says a different thing: a distinct status and a distinct log kind, so an operator reading a stopped run at two in the morning can tell "raise the cap" from "the shard did not answer" at a glance. **A step waiting on a human is `running` with a NULL lease** (Phase 2, below). |
### The scheduler
One worker, `utils/eventRunner.js`, in the **website** process beside the other seven — not the bot,
which cannot load module code. Same `setInterval` + `unref()` + `stop()` shape, wired into
`server.js`. Its tick does three things in order: **materialise** due occurrences per scope with
`INSERT IGNORE` against the unique key; **advance** runs by claiming `scheduled → starting` and
evaluating phase conditions; **drain** due steps, checking caps, dispatching, classifying, recording
resources.
**As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died),
**materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock.
**A phase advances on every one of its steps going terminal — and, if it authored one, on its GATE
being open as well** (Phase 5). The gate is an *additional* condition and never a replacement: a
phase whose steps are still running is not advanced by a boss that spawned early, and the force
control refuses a phase held by a step for the same reason. A phase with no gate behaves exactly as
every phase did before Phase 5, and carries no `advance` key at all.
**Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's
recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon**
(`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the
occurrence key — so the tick that already made one makes nothing, which is what lets it run every
fifteen seconds for ever. The second SWEEPS: a run whose instant passed while the process was down
becomes `missed` rather than starting late and silently.
**The two halves need each other, and the horizon is why.** Expansion looks forward from
`now - grace_seconds` only, so an occurrence nobody ever materialised is never invented
retroactively — waking up after three days down must not manufacture three days of `missed` history
that no operator could have seen or cancelled. It does not have to: because rows exist a fortnight
ahead of their instant, an outage spanning an occurrence finds the row already there and the sweep
marks it honestly. **The horizon is what makes the missed sweep mean anything for a recurring event.**
**Automatic expansion is at the empty scope** (org lead, 2026-09-02). A fan-out across named scopes
needs a registry of what a scope *is*, which no phase owns yet; inventing one before the module
contract would be a contract the modules were never asked about. An admin's own
`POST /admin/events/:id/runs` still takes any scope.
Three numbers govern a step, and they live in the runner rather than in a column because no authoring
surface would ever show them: `EVENT_STEP_MAX_ATTEMPTS` (3), `EVENT_STEP_RETRY_MS` (60 000, flat), and
`EVENT_RUN_LEASE_MS` (15 minutes). A step's own lease is not one of them — it is computed from that
action's declared `budgetMs` plus a minute, because a registry that lets an action declare an hour
would otherwise have its steps reclaimed and re-dispatched fifty-nine minutes before they answered.
**A run's status is re-read between steps, not only at the top of a tick** (Phase 3). One tick drains
up to `EVENT_STEPS_PER_TICK` steps from one run, so without this a pause pressed mid-batch would be
answered by dispatching another two dozen steps — which is not a pause. It is one indexed read per
step, against a control whose entire value is that it takes effect at once.
**Serial within a phase.** The runner works the lowest-`seq` step of the current phase that is not
terminal, and does nothing with the one after it until that one finishes. This is the only reading
under which `core.wait` means anything, and the only one under which a cue can gate what follows it.
**Two scheduling decisions the calendar forces.**
*Schedules are timezone-aware, and the timezone belongs to the event.* Every EM listing is in the
shard's local zone — KST, JST, PDT, CDT, CEST — never UTC. A recurrence computed in UTC drifts across
a DST boundary and puts a Friday-8pm event at 7pm for half the year. Store the IANA zone on the
definition, compute occurrences in it, store `scheduled_for` as UTC.
*Recurrence is a small closed shape, not cron.* `{ kind: 'once', at }`,
`{ kind: 'weekly', days, time }`, `{ kind: 'monthly', nth, weekday, time }`, `{ kind: 'manual' }`.
The only cron precedent is in the bot, in another process, with no parser in the server dependency
tree — and a cron string is the one field an operator cannot proofread. Monthly-nth is not padding:
the fishing contest on Drachenfels is exactly that shape.
**As built in Phase 4.** `time` is `HH:MM` and `days`/`weekday` are English weekday names rather than
numbers, for the same proofreading reason that rejected cron; `at` is a **local wall clock**
(`YYYY-MM-DDTHH:MM`) in the definition's own zone, never a UTC instant, because the schedule belongs
to the event and the instant is derived at materialisation. `days` is normalised into week order, so
two spellings of one schedule do not show as an edit nobody made in the version history. **`nth` is
`1..4` or `-1` for "last"** (org lead, 2026-09-02): every month has a first through fourth of every
weekday, so the closed set has no absent-occurrence case to define, and `-1` is the shape a
"last Friday" contest actually is — it is not a synonym for `4`.
There is **no date library in the server's dependency tree** and Phase 4 did not add one. Node ships
the full tzdata behind `Intl.DateTimeFormat`, which is the same database a library would vendor a
copy of and is already what the zone-name check uses. `events/recurrence.js` inverts the
instant-to-wall-clock mapping by search; it is the one place an occurrence is computed, so the
runner's expansion and the calendar's forecast cannot disagree.
**The two DST rules** (org lead, 2026-09-02), which exist because a weekly 02:30 event in
`Europe/Berlin` is a thing an operator will really author:
- A **nonexistent** local time — the spring-forward gap — steps forward to the first wall clock that
does exist. 02:30 becomes 03:00, not 03:30: the event happens as close to the authored time as the
calendar allows.
- An **ambiguous** local time — the fall-back hour, which comes round twice — takes the **first**,
at the pre-transition offset.
Neither rule ever drops an occurrence: a weekly event happens every week. Both are recorded on the
run as `detail.dstAdjusted`, so nobody has to rediscover daylight saving at 3am on the last Sunday in
October.
**Publishing is the schedule switch, and archiving is how it is turned off.** `ready` is defined as
"a version has been published and the schedule is live", so a second enabled flag would be another
answer to a question `state` already answers, and the two would eventually disagree. The expansion
reads the **published version's** spec, never the definition's working copy: a half-typed recurrence
an author is midway through must not materialise anything.
**Publishing also re-pins the occurrences that have not started** (org lead, 2026-09-02). Every run of
that definition still `scheduled` with a NULL `started_at` moves to the new version, and the count
comes back on the publish response. A version pin exists to make a run that **has run** reproducible;
a run that has not begun has nothing to reproduce yet. Without this an editor's fix would reach none
of the fortnight already on the calendar, and the only recourse — cancelling each stale occurrence —
is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occurrence`, so the
occurrence would not come back on the new version, it would vanish. A run that **has** started keeps
its pin for ever.
### Advance conditions, and the two writers a gate has
A phase's `advance` is one of exactly two shapes, both validated at save:
| Shape | Means | Closed by |
| --- | --- | --- |
| `{ after: '30m' }` | thirty minutes from the moment the phase was **entered**, whatever its steps took | the runner's tick, `satisfied_by: 'elapsed'` |
| `{ on: '', where: , count: n }` | `n` firings of that trigger that satisfy the predicate, counted from phase entry | the **emit path**, `satisfied_by: 'condition'` |
`after` is one integer and one unit from `s m h d`, normalised to its largest whole unit — `'120m'`
is stored as `'2h'` — for the reason `days` is normalised into week order: the spec is diffed between
versions, and two spellings of one delay differing as JSON is a version history reporting edits
nobody made. `count` defaults to 1; `where` is optional and `null` means any firing.
**The two shapes are closed by two different writers, and that division is the design.** A gate
waiting on three boss spawns is counting things that happen *between* two ticks; fifteen seconds
after the third spawn there is nothing left for a poller to observe, and a tally held in a process's
memory is one a restart silently returns to zero — with the phase then waiting for three more of
something that already happened. So `ctx.events.emit` writes the count where it happens, beside
`engine.dispatch` and with the same posture (never awaited, never allowed to reject), and the tick
reads the row. The increment is **one statement with the threshold inside it**, the argument
`event_run_budget`'s conditional increment makes one phase early: two firings arriving together each
add one and exactly one of them crosses `needed`.
**The clock and the tally both start at phase ENTRY**, not when the steps finish. `after: '30m'` is
thirty minutes after the phase began however long its dispatches took, and a trigger that fires while
the announce step is still in flight counts. Both are what makes a gate predictable from the authored
spec alone.
**A gate that never opens is held, and made loud** (org lead, 2026-09-02). There is deliberately no
authored timeout and no disposition to choose: nothing advances a phase but its condition or a human.
What the engine does instead is *say so* — an `on` gate that has waited past `EVENT_PHASE_STALL_MS`
(one hour) takes the run's `health` to **`stalled`**, §E's third value and the first thing in this
system ever to write it, logged once. It has to be loud, because a held run also holds its
concurrency key, so every later occurrence of the same definition goes `missed` behind it. An
`after` gate is never stalled: a phase waiting out six hours it was authored to wait is working, and
health that said otherwise would train an operator to ignore it.
**Health is a high-water mark, and `setHealth` now enforces it.** Nothing has ever cleared
`degraded` — a run whose announcement landed on the second attempt did have trouble, and that stays
true for the rest of its life — so the write is escalation-only. Without that guard, a step retrying
after a stall would demote `stalled` back to `degraded`, and a run that waited ninety minutes on a
boss that never came would end its life claiming it merely wobbled.
**A gate on a trigger nothing registers is DORMANT**, exactly as a step naming an unregistered action
is: it saves, so uninstalling a module is not destructive to an author's work, and it refuses to
publish, because a version runs are pinned to must not wait on a trigger that can never fire. Its
predicate is carried through unvalidated rather than dropped — there is no declaration to check it
against, and deleting it would silently discard what the author wrote.
### Enablement and caps, in front of every dispatch
Phase 6. The runner gained one thing: before it asks a module to do anything, it asks
`mayInvoke()` whether it may — [§K](#k--security-model)'s four layers behind one function. Three
properties of *where* that check sits are load-bearing.
**It is after the claim and before the dispatch.** After, because the cap is held by a conditional
`UPDATE` and two ticks that both priced a step before either claimed it would both spend. Before,
because a refusal means the action does not happen at all: nothing is sent, nothing is created, and
the step never reaches the module.
**A retry does not pay twice.** The spend happens on the first attempt only. A retry re-dispatches
the same idempotent operation against the same key, and 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 step costing two dimensions can be refused on the second**, and the first is given back. The
spends are separate statements — the atomicity that matters is per dimension, and a transaction
spanning three of them would serialise three unrelated counters to buy nothing — so the partial spend
is real and has to be unwound. A step that did not run must not have spent anything.
**The run's budget is seeded from every phase's steps at creation**, not phase by phase as the run
enters them. The version is pinned and immutable, so all of its steps are knowable at once, and a
budget that grew as phases were entered would let a phase-1 step spend what a phase-3 step was going
to need — the opposite of a per-run bound. **The caps are copied**, so an admin moving a switch
tomorrow does not change what a run already in flight is allowed, and the console's meter afterwards
answers *"what was this run allowed"* rather than *"what is allowed now"*.
**Two actions spending one dimension resolve to the tightest cap** (org lead, 2026-09-03), pinned at
creation with the action it came from. `event_action_settings.caps` is per action while
`event_run_budget` is one row per dimension, so the two have to agree on one number, and the number a
safety limit should settle on is the smaller. An action that declines to cap a dimension never raises
a ceiling another one set — `null` is uncapped and does not win a minimum — because otherwise adding
a second verb to an event would silently remove the bound on the first.
**The runner never re-checks the role of whoever started the run.** `mayInvoke` takes a null user on
this path and skips the role layer entirely. The role was checked when a human published the version
and again when a human or the scheduler started the run; re-checking at three in the morning would
mean an unrelated personnel change silently strands every event an admin started.
[§K](#k--security-model)'s *"a demoted user loses access at once"* is about reaching a route, and it
still holds exactly there. Cancel is the control for a run that should stop.
### Concurrency
| Contention | Protection | Not an in-process mutex, because |
| --- | --- | --- |
| Two instances materialising one occurrence | `UNIQUE (definition_id, scope, scheduled_for)` + `INSERT IGNORE` | the database is the only thing both instances share. |
| Two instances starting one run | CAS `scheduled → starting`; the instance told `affectedRows = 1` owns it | verbatim the outbox claim chosen over `SKIP LOCKED`. |
| Two instances running one step | CAS `pending → running` plus a lease | the lease covers the instance that died mid-step. |
| Two steps spending one cap | Conditional increment with the cap in the `WHERE` | a read-then-write would let two steps each see 28/30 and both spend. **The guard must stay in the `WHERE`**: MariaDB evaluates a `SET` list left to right with the values already assigned, which is how Phase 5's gate closed a firing early, so nothing in this statement may read `consumed` after writing it. Proved against a real server, not a stub. |
| An orphaned claim | Reclaim on lease expiry, **without resetting `attempts`** | Engagement Phase 14's exact defect: a reclaim that reset state made `MAX_ATTEMPTS` unreachable and the row cycled forever, never terminal and therefore never retention-eligible. |
| Two events overlapping | `concurrency_key` as a **template rendered from the run's params** — e.g. `invasion:{region}` | a flat definition-id key would wrongly stop the same definition running on two Rust servers, or in two regions, at once. |
**What happens to the run that loses.** It is **held at `scheduled`**, not failed and not queued
(org lead, 2026-09-02). Every tick re-examines it; if the holder finishes inside the grace window the
run starts, and if it does not the missed sweep makes the run terminal and visible. Failing it
immediately would say the system broke when in fact it correctly declined to overlap two events, and
queueing it indefinitely would let an event whose announcement said 8pm begin at 11pm — the exact
thing `missed` exists to prevent. The reason is written to `last_error` and logged as `run.blocked`
**only when it changes**, because a line per tick for the length of a grace window buries the one
line that matters.
> **This deployment runs one app instance, and every protection above is built anyway**
> ([§N4](#n--decisions)). The "two instances" column names the *hardest* contender for each row, not
> the only one: the unique index and the CAS equally protect a tick that runs long while the next one
> fires, and the lease and its reclaim are what recover a step whose process died mid-dispatch. Both
> happen with one container. What the single-instance decision changes is the **test**, not the
> design — P2 does not build the `--scale app=2` rig — so the multi-instance property is true by
> construction and unproven by experiment. Scaling this deployment, or adopting a rolling deploy that
> briefly runs two containers, is the trigger to build that rig before anything else.
### Idempotency
```js
// Core mints it once, at step materialisation, and it does NOT vary by attempt.
// A retry re-sends the same key, so the game side can recognise the repeat.
idempotencyKey = sha256(runId + '|' + stepId) // 40 hex, as shardEvents.dedupeKey does
```
It rides the whole path: core → module `perform()` → sidecar → plugin. Core guarantees stability; the
module passes it through unchanged; the **plugin** keeps a bounded recent-key set and answers a repeat
with the original result rather than acting twice. That last part is a new plugin capability and a
protocol field, and it is non-negotiable before any world write ships — a 10-second timeout on a
command that *did* apply is otherwise indistinguishable from one that did not.
---
## F — The module contract
One new registry and two new declarations, at MODULE_API **1.10.0**. Additive; no signature changes;
module-uo's `coreApi: "^1.9.0"` continues to resolve.
> **Built in Phase 7** (`website#189`), and the seam it opens is narrower than the four names suggest.
> `registerEventActions` has existed since Phase 1 and has staged core's three actions on every boot
> since; what it never had was a way in, because `loader.js` builds its own `api` facade and had no
> method that delegated to it. So Phase 7 forwards four names, adds the three declarations beside the
> action registry, and makes the fail-closed budget rule real. 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 — which is the argument `registerCore()` has made since the module system's Phase 3, and
> this is the phase where it pays.
>
> **`registerEventOptionSources` is a fourth registration, not a field on the action that names a
> source** (org lead, 2026-09-03). A catalog has more than one consumer — `uo.options.items` is the
> allowlist for granting an item and for taking one back — and two actions declaring it separately
> would be two allowlists that can disagree.
>
> **A lease is declared here and acquired by `core.lease`.** Core owns a lease's duration and its
> conflict check; both live in the resource ledger, which Phase 8 built. Declaring it in 1.10.0 rather
> than waiting is what keeps the module contract one version a module author reads once rather than
> two — and `reconcile` was folded into the same version for the same reason, in place, because a
> protocol owes a bump only once it has landed on `main`.
```js
api.registerEventBudgets([
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' },
{ id: 'uo.bosses', label: 'Boss-tier creatures', unit: 'count' },
{ id: 'uo.npcs', label: 'Event NPCs', unit: 'count' },
{ id: 'uo.gate.minutes', label: 'Gate uptime', unit: 'minutes' },
])
api.registerEventActions([{
id: 'uo.creature.spawn', // .-prefixed; its OWN id space
label: 'Spawn creatures',
description: 'Places named, hued creatures at a location for this event.',
risk: 'change', // closed: notify | inspect | change | irreversible
reversible: 'ledger', // closed: none | self | ledger | override
version: 1,
budgetMs: 10000,
// What one invocation consumes. A function, because it depends on params.
cost: (p) => ({ 'uo.creatures': p.count }),
params: [
{ name: 'location', type: 'string', required: true,
example: 'Britain Hall', source: 'uo.options.landmarks' },
{ name: 'creature', type: 'string', required: true,
example: 'Orc', source: 'uo.options.creatures' },
{ name: 'count', type: 'int', required: true, example: 12 },
{ name: 'name', type: 'string', required: false, example: 'Blackthorn Raider' },
{ name: 'hue', type: 'int', required: false, example: 1157 },
],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {
// verify === true => validate and report, change NOTHING
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
},
async revert({ runId, resources, idempotencyKey }) { // required iff reversible === 'ledger'
return { ok: true }
},
}])
```
> **Everything a module registers is optional.** Stated once because it governs every member. A
> module may register no actions, no leases, no budgets and no option sources, and core is then an
> event engine that can announce, wait, cue a human and publish results — which is a working product
> ([§M](#m--phased-implementation-plan) P1). Each registration *adds* what an author can reach for;
> none of them is load-bearing for the engine, and a module that omits one costs its deployment a
> capability rather than a boot. This is the posture the whole module contract already takes: a
> module with no `onBoot` still reaches `started`.
### Leases: one more declaration
A lease action declares what it can hold and how long it may hold it. Core owns the duration and the
conflict check; the module owns reading the current value and writing a new one.
**The verb is core's** (org lead, 2026-09-03, Phase 8). A module declares the lease and never writes
one: an author puts `core.lease` in a step, naming the lease, a value and a number of minutes, and
core reads the baseline, reserves the target, applies the value with its deadline, and restores it at
teardown through the module's own `restore()`. A lease verb per module would be the `maxDurationMs`
bound and the two-events-one-target check re-implemented once per module — advisory everywhere, and
wrong in the first one that forgot. It also means `core.lease` needs no `revert()` of its own: an
`override` row is restored through the lease registry, which is exactly the split this section draws.
`core.lease` is `risk: 'change'`, so it is default-off, admin-only and cap-checked like any module
verb — the first core action for which that is true.
```js
api.registerEventLeases([{
id: 'uo.rate.skillgain',
label: 'Skill gain rate',
type: 'float', min: 0.5, max: 5,
maxDurationMs: 86400000, // core refuses a longer lease at authoring time
async read() { return { ok: true, value: 1.0 } },
async apply(v, until) { return { ok: true } }, // `until` goes DOWN THE WIRE
async restore(baseline, { expected }) {
// MUST verify current === expected before writing. Mismatch is drift,
// not an error: answer { ok: false, drifted: true, current }.
return { ok: true }
},
// Optional (Phase 11b). "Does the game side still have any record of this
// hold?" — a DIFFERENT question from `read`, and the only thing that takes a
// lease's ledger row out at reconcile.
async inForce() { return { ok: true, held: true } },
}])
```
### What Phase 7 settled about the three declarations
- **A budget's `unit` is required and its vocabulary is open.** Required because a bare number on a
cap box is ambiguous in exactly the case that matters — 30 of what? — and open because core never
interprets it. It is a display word beside a number, and closing the set would make "kilometres" a
MODULE_API bump for a noun core does not read.
- **A `cost()` naming a dimension nobody declared is REFUSED** — at save, at the dry run and at
dispatch, with its own code (`undeclared`) rather than the cap layer's (org lead, 2026-09-03). It is
what makes *"a module cannot spend a budget it did not declare"* a rule instead of a sentence, and
it is checked 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.
- **WHICH dimensions an action spends is still discovered by pricing its declared examples.** A
registry can say what a dimension is called; it cannot say which action spends it, because `cost` is
a function of params and calling it is the only honest way to ask. So Phase 6's example-pricing
stays as the discovery, and `registerEventBudgets` supplies the label and unit the switchboard
renders. A dimension an action prices but nobody declares is **shown** on that screen rather than
filtered out, because the action is refused and the operator needs to be told which module is
incomplete.
- **`inForce()` is the fourth, it is optional, and it is not `read()` with a comparison**
(Phase 11b). It answers *"does the game side still have any record of this hold?"*, which none of
the other three do — and it had to exist because a lease's ledger row has no reconcile path
otherwise: the step that made it names `core.lease`, which is core's own action, so there is
nowhere a module could hang the answer. A value that DIFFERS from what the run applied is drift,
which `restore()` reports so the row lands `drifted` with the current value beside it; inferring
absence from a changed value would orphan the row first and destroy that signal. Only an explicit
`{ ok: true, held: false }` takes a row out; a throw, a timeout and a lease with no `inForce()`
all leave the ledger alone. It matters most for exactly the case core could not see before: a
config lease is memory-only on the shard, so a restart reverts it *and* clears the record.
- **A lease declares all three callables, and `restore` is not optional even though `read` could
stand in for it.** They answer different questions: `read` is *"what is it now"*, `restore` is
*"put this back, and tell me if someone else has moved it"* — the drift check, which is the one
thing a module must not be allowed to skip. A lease whose restore writes blindly silently reverts
an operator's manual fix. The numeric types carry a **required** `min`/`max`, because unlike a cap a
bad lease value is in force the moment it is applied.
- **An option source that refuses degrades its field to free text with a warning, and its route
answers `200`.** Both halves are the contract. A `4xx` would make an authoring screen something a
module's outage can take away, for a field whose value the operator very often already knows — a
worse failure than the typo the dropdown prevents. Its values are resolved on their own request
(`GET /admin/events/catalog/options/:sourceId`) rather than inside the catalog, so a slow source
cannot take the catalog with it, and per request rather than once, so a leg registered by a module
that booted later still appears.
- **Core registers an option source of its own** (org lead, 2026-09-03). `core.announce`'s `leg` param
was a free-text box whose typo was caught at DISPATCH, mid-run — which is the defect Phase 6's own
walk hit, an announce leg `"site"` no module registers. The legs are already a registry with labels
in them, so `core.options.legs` costs nothing new, and it means the seam's first exercise is not a
module's.
### What Phase 9 settled, as the contract's first module registrant
Wave 1 — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post` — is the first time anything but core
registered an action, and three of the four things it found are rules rather than facts about UO.
- **`budgetMs` must EXCEED the timeout of whatever the action talks to.** The dispatcher classifies a
budget timeout as `retry` unconditionally and does not ask the action — it cannot, the action is
still awaiting a socket. So an action whose own client gives up *after* core's deadline never gets
to classify its own failure, and `retry: false` in its envelope is unreachable. The default
`budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, the wrong way round, and every slow
shard produced a retry the module had explicitly refused. Now a rule in `MODULE_API.md` §2.4.
- **There is no per-action "do not retry me", and `on_failure` is not one.** `on_failure` is what
happens AFTER `EVENT_STEP_MAX_ATTEMPTS`, so `skip` — already the `notify` default — does not stop a
broadcast going out three times. The lever is the failure envelope, and an action that must be
attempted once answers `retry: false` to everything. That is a heavier thing to ask of a
declaration than the plan assumed, and it is the reason the rule above is load-bearing rather than
a tuning note.
- **`reconcile()` need not be a query.** A module that knows *when* its resources are lost can answer
from a stamp instead of a round trip: wave 1 records the shard's `bootId` in the resource payload
and reports in force exactly the rows whose stamp still matches. That is correct for the module's
own trigger AND for core's boot sweep — where the game may not have restarted at all, and where
answering "all gone" would abandon live rows. A row with no stamp is reported IN FORCE, because
"I do not know" must never read as "it is gone", which is `reconcileModule`'s own posture stated
from the module's side.
- **A module's `reconcile()` trigger must fire on a LIVE signal, never a replay.** §L says the module
says when; it did not say that a module's event feed may replay history. `module-uo`'s sidecar
reconnect backfills the last several `server.hello` frames, each with a different `bootId`, so every
replayed frame reads as a restart — and an intermediate one compares a resource stamped with the
current boot against a boot that ended hours ago and orphans it. A live resource core will never
take back, lost to a reconnect. Any module wiring this seam owes the same gate.
### What Phase 10 settled, wiring the engine to everything else
Phase 10 is the integrations — core's own `event.` triggers, `event_run_participants`, results
publication, and `core.announce.post`. Four things it settled, and three of them are rules rather
than facts about events.
- **A firing may carry a ceiling of its own, and it may only ever NARROW.** A trigger's declared
ceiling is a property of the KIND of event; a rehearsal is a property of the occasion, and the two
are different questions. §I promises an event can be "run for real with announcements ceilinged to
`staff`", and nothing implemented it — a rehearsal of a published event fires exactly the same
trigger as the real thing, so it would have mailed every subscriber. The emit envelope now takes an
optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It is a
MODULE_API change (1.11.0) because the envelope is a module's too, and the alternatives were worse:
a rehearsal that emitted nothing would be a rehearsal of everything except the announcements, and a
parallel set of `event.rehearsal.*` ids would make every operator write each rule twice.
- **Two incomparable ceilings refuse rather than resolve.** `meet('owner', 'staff')` is null — they
have no common descendant, which is the whole argument `modules/ceilings.js` is built on — and the
gate then refuses every rule. That is §5.1a rule 3's posture, reused rather than re-argued, and the
reason a narrowing ceiling could not simply be "the smaller of the two".
- **A module reports participants on the success envelope, beside its resources** (org lead,
2026-09-04). There is no `ctx.events.participants` and no route: a second write path into a run
core is mid-tick on would be a second thing that can race the claim, for a caller that does not
exist until a module can source attendance at all. Core stores what it is told and sources nothing
— `member_key` is module-opaque and `user_id` is filled in only by whoever knows the link table,
because a core that guessed would be one game's identity model compiled into core.
- **`affectedRows` cannot tell an INSERT from an unchanged upsert here**, and a "was this new" flag
read off it would have counted every idempotent retry as a fresh participant. The connector sends
`CLIENT_FOUND_ROWS`, under which an `ON DUPLICATE KEY UPDATE` that changes nothing answers 1 —
exactly what an insert answers. The recording path counts what it was given instead. Same family as
Engagement Phase 4a's `foundRows` trap, one flag along.
- **A trigger's `description` is read by a RECIPIENT, not only by an operator.** It is the rule
editor's catalog text and, through the structural projection's `intro` fallback, the body of every
unauthored render — so a line that names its own audience (*"Staff-facing."*) is a line that ends
up in that audience's inbox. Write it as prose the reader can read; who a trigger is for is what
its ceiling says, and the ceiling is the only place that can enforce it.
- **A trigger that is not also a STREAM has no push, and says nothing about it.** Push delivery joins
`notification_subscriptions`, which is only ever written for an id a user could switch push on for
— and `notificationChannelPrefs.catalog` offers the push channel only for registered streams. So a
rule naming `push` on a trigger-only id enqueues a tickle nobody can subscribe to, `publishToUsers`
finds no endpoint, and the send log records it `sent`. The seeded `run.started` rule named push
before the live rig caught it. **`event.run.started` is now a stream as well as a trigger** (org
lead, 2026-09-04) — the same-owner upgrade `news.post` already is — and the other six are not: push
is the channel that says *now*, and the rest are things a player reads when they next look. The
containment runs one way, and that is the rule this settles: **every stream must be a trigger**, or
it is a push toggle for something no rule can fire; a trigger need not be a stream.
**And one thing the tree corrected about this phase's own plan.** Core does not seed through
`registerEngagementSeeds`: that door is module-facing and requires template keys namespaced
`.`, while core's own bodies live in `engagement/templateSeeds.js` and its rules in
`engagement/coreRules.js` under a per-group one-shot key. Phase 10 uses core's mechanism, which is
also what keeps Engagement Phase 11's rule intact — one key per seed GROUP, so a deployment that has
already stamped the Team and news keys still receives these two.
### What is contract rather than implementation
**Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02).
Both are ordinary envelope members rather than special cases keyed on an action id, so the runner
never names a verb, and a module's own long-running action reaches them through the same door core's
does:
```js
return { ok: true, await: 'human' } // PARK. The step stays `running` with a NULL lease;
// nothing advances until a human confirms it.
return { ok: true, holdFor: 300 } // FINISH, and delay what follows by 300s. The pause is
// the NEXT step's `due_at`, owned by core.
```
`await: 'human'` is what makes the GM cue work, and the NULL lease is load-bearing: the stale reclaim
only ever takes back a lease that is **non-NULL and expired**, so a cue posted on Friday is still
waiting on Monday rather than being re-dispatched every fifteen minutes. `holdFor` is what makes
`core.wait` a no-op at dispatch — a `perform()` that slept would hold its claim for the duration, turn
a five-minute pause into a five-minute lease, and be re-dispatched by the reclaim, so a long enough
wait would never end. It is bounded at seven days.
**A `holdFor` on the last step of a phase holds the next phase**, rather than meaning nothing. The
later phase's steps do not exist at that moment — they are materialised on entry — so the instant is
carried across the boundary and applied to the new phase's first step. Dropping it would make
"announce, wait five minutes, then the next phase" start the next phase at once, which is a wait that
silently did nothing.
- **Every method answers with an envelope, and no shape a failure can take reads as success.**
`registerTeamProvider`'s load-bearing rule, inverted: the team provider's default on refusal is
"keep what you have" because staleness is cheap; an action's default is **"nothing happened,
retry"**, because the expensive mistake is recording a world change that did not occur. A rejected
promise, a throw, a timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`.
- **`cost` is declared by the module and enforced by core, before dispatch.** A module says what a
verb consumes; core owns the caps and the arithmetic. A module cannot spend a budget it did not
declare, and cannot under-report to escape a cap without lying in code the operator installed —
which is the same trust category as its schema fragment.
- **`risk` is declared and core-enforced, over a closed four-value set.** The values are
`notify · inspect · change · irreversible` — deliberately *not* "world-read/world-write", which are
game words a chess ladder has no use for.
- **Params are validated at save *and* at dispatch, against the declared version.** A step stores the
`action_version` it was authored against; a bump makes it render a warning in the editor rather
than dispatch a mistyped parameter. A param's `example` is **required**, on the optional params as
well as the required ones — the same rule `registerEventTriggers` makes of a variable's example and
for the same reason. It is the authoring form's placeholder, it is one word at declaration time,
and it is unreconstructable afterwards; a blank box is how an unattended world write comes to be
scheduled with a typo in it.
- **Resources are named by the module and owned by core.** `kind` and `ref` are opaque strings core
stores verbatim — `ctx.teams.activity.push`'s exact treatment. Core does the remembering; the
module does the meaning.
- **`verify: true` must change nothing and must answer honestly.** Dry run is the project's own idiom
(`installer --verify`, `deploy.ps1 -Verify`) and it is the only way an operator will trust an
unattended, scheduled world write.
- **A lease's deadline goes down the wire, and the game side honours it without being asked again.**
The module passes `until` through to its sidecar and the plugin restores baseline when it passes. A
module that treats `until` as advisory has produced a lease that outlives an outage, which is the
one thing a lease exists to prevent.
- **A module may only advertise a lease it has verified takes effect.** 258 `Config.Get` call sites
in ServUO split between live reads and values cached at type initialisation, and a lease on the
second kind applies cleanly and does nothing. Apply, observe, restore — per key, as a test.
- **`scope` is opaque and may be null.** Core passes it through and never interprets it. Core has
**no concept of "the game being up"** — only `{ ok: false, retry: true }` — because a module with
six sidecars cannot answer that question in the singular.
- **A module says when to reconcile, because core cannot.** §L's reconnect row asks each ledgered
resource's module what is still in force, and the trigger for that question is `ctx.events.reconcile()`
— the module's own call, made when it sees its own reconnect (module-uo already watches `bootId` to
tell a shard restart from a sidecar reconnect). Core has **no concept of the game being up**, which
is the same sentence three bullets down, so the alternative would be core guessing. Core asks once
at its own boot, which is the one reconnect it can see. `reconcile()` is OPTIONAL where `revert` is
required: a module that cannot answer leaves core believing its own ledger, which is the behaviour
before this phase, while a module that created something and cannot undo it has made a promise core
has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }` changes nothing:
**"I do not know" is never read as "it is gone"**.
- **An action whose module is uninstalled goes dormant, never an error.** A step naming it fails
`terminal` with the module named and the run degrades — never a silent skip. The authoring side
draws the same line one step earlier, in the shape `engagement_rules` established for a dormant
trigger: **a step already in a saved spec may keep an unregistered action and a new step may not
add one**, so an uninstall is never destructive after the fact — and a dormant step blocks the
*publish*, because a version is what a run pins and a run cannot dispatch a verb nobody registers.
- **Actions and budgets are their own id spaces.** An action names a verb, a trigger names an event,
a budget names a resource dimension.
### Rewards are not a contract member. They are an ordinary action.
An earlier draft had a `registerEventRewards` registry whose entries carried `type`, `cliloc`,
`itemId` and `hue`. That was four Ultima Online nouns inside a core signature, and it was wrong for
exactly the reason the rest of this document is careful about. It is deleted.
```js
// module-uo — an item into a backpack. Once given, it is gone.
{ id: 'uo.item.grant', label: 'Give an item',
risk: 'irreversible', reversible: 'none',
cost: () => ({ 'uo.rewards': 1 }),
params: [{ name: 'item', type: 'string', required: true,
example: 'PlateChest', source: 'uo.options.items' }] }
// module-rust — a VIP group for the weekend. Genuinely a LEASE, not a gift.
{ id: 'rust.group.grant', label: 'Grant a group',
risk: 'change', reversible: 'override',
params: [{ name: 'group', type: 'string', required: true,
example: 'vip', source: 'rust.options.groups' }] }
```
**Four things this collapse buys, and one it required.**
- **Rewards are optional by construction, not by an omitted registration.** A module that declares no
such action simply has none, and core has no reward concept sitting empty.
- **The implementation varies freely between games, because core never sees it.** UO grants an object
into a backpack. Rust might grant a kit, a permission group, currency through an economics plugin,
or a cosmetic — and **some of those are revocable**. A weekend VIP group is a *lease*, with the same
deadline and drift check as any other. Fixing every reward at `reversible: 'none'`, as the deleted
draft did, would have made that inexpressible.
- **The cliloc catalog becomes an implementation detail of module-uo's option source,** where it
belongs, rather than a field in a core contract. Clilocs name items and `item_id` pictures them —
already imported for the market pages, already offline — but **cliloc → ServUO type is not a
function**: many types share a label and a label constructs nothing. So module-uo's
`uo.options.items` answers from a plugin-declared constructible allowlist and dresses each entry
with the label and icon it already holds. Core sees `[{ value, label }]`, as for every other option
source.
- **`uo.creature.spawn` needs no catalog at all.** `shard_spawn_creatures.slug` is documented as the
*"slugified class name"* — roughly 800 constructible types with display spellings and facet
distribution, re-derived from the shard's own tree on every boot. The atlas is already the
allowlist.
- **The one thing it required:** a resource may carry an optional `memberKey`, so core can record an
outcome against a participant and answer "who received what, from which step, in which run". One
optional field on a shape that already exists — not a registry.
### Param option sources
A `source` names a module-served option endpoint, so an authoring field is a dropdown of *real*
landmarks and creatures rather than a text box an operator can typo. The module answers
`[{ value, label, group? }]`, core caches, and a refusal degrades the field to free text with a
visible warning rather than blocking the form. For module-uo it is answered entirely from the spawn
atlas — offline, with the shard down. Without it the authoring UI in [§I](#i--admin-ui-authoring-and-the-calendar)
is a JSON editor with better fonts.
### Integration Kit
A fifth chapter, obeying the kit's own rule — teach and link out, re-specify nothing. The four things
a second module's author will get wrong are the envelope's failure default, the idempotency
passthrough, recording a resource *before* confirming it, and under-declaring `cost`. All four are
one paragraph each and all four are invisible until an outage.
> **Built in Phase 15** (`Integration-kit#10`), held unmerged until the cutover by the same mechanism
> Teams phase 11 was: `checkCoreApi` asserts EQUALITY between `template/module.json`'s `coreApi` and
> the `MODULE_API_VERSION` of the core `ci/core-ref.json` pins, so a template declaring `^1.10.0`
> against a `main` still on 1.9.0 is red on purpose from the day the branch opens. The pin move is
> the kit's leg of P16.
>
> **It is three chapters, not one.** The book as it stood taught a read-only data path end to end —
> a sidecar that listens and stores, a plugin that never blocks the game thread, a module that reads
> its own tables. Nothing in it told anyone to build a **command** path, so a chapter 5 teaching a
> module to send an idempotency key would have been addressing it to a sidecar with nowhere to put
> it. Chapter 3 gains §2a (request/reply correlation, the at-most-once store belonging where the
> state is, and a deadline sent as a duration rather than an absolute time) and chapter 4 gains *"A
> command that changes the world runs at most once"* (the key store persisted in the world save, the
> ownership registry, and the expiry this side arms and re-arms at load). Both open by saying they
> are skippable until you want chapter 5.
>
> **The template ships one of each of the four declarations**, and the phase's value came from
> running them through core's real registry and real `events/dispatch.js` classifier at `edge` rather
> than from the prose. That found two things:
>
> - **An idempotency key belongs on a command and never on a question.** A read that carries one is
> answered by an at-most-once store with the FIRST read's reply, forever — so a lease applied
> correctly, the game changed correctly, and the module could no longer see either: `read()`
> reported the pre-run baseline and `inForce()` said nothing was held. The narrower rule that falls
> out is worth stating with the others: a key is for a write whose repetition would be a second
> EFFECT, and a write that merely SETS a value to X is idempotent by its own nature.
> - **§H names a `detail` member on an envelope and core has never read one** — see the correction
> below.
> **`detail` was not an envelope member, and now it is** (`website#197`). §H's Rust wipes row told a
> module the revert contract accepts `{ ok: true, detail: 'resource no longer exists' }`;
> `events/dispatch.js`'s `classify()` read `ok`, `retry`, `error`, `await`, `holdFor`, `resources`
> and `participants` and had never read a `detail`. **`module-uo` took §H at its word twice** —
> `uo.item.grant` answers `detail: { granted, missed, why }` and `uo.world.save` answers
> `detail: { started: true }` — and both were writing into nothing. The grant is the one that
> mattered: a grant reaches the players a run's participation ledger holds, and *which of them missed
> out* is knowable only to the module, so an operator saw a step `done` and never learned four of
> twelve got nothing.
>
> **Fixed by making the member real rather than by deleting the reporting**, because §H's sentence
> was right and only its example was wrong. `detail` is now optional on both SUCCESS shapes,
> **carried and never interpreted** — nothing in the dispatcher, the runner or the browser reads a
> key out of it — and the runner writes it as a `step.detail` run-log line. Its own log kind rather
> than a field on `resource.recorded`, because the grant that forced it ledgers nothing
> (`reversible: 'none'`) and reports no participants, so it would have had nowhere to ride.
>
> Two things about the fix are worth keeping. **Anything wrong with a `detail` is dropped and logged,
> never a failure** — a step that did what it was asked must not be re-run because its module's
> commentary was malformed, which is a world write repeated for a log line. And **the console's
> renderer was half the fix**: `describeLogLine`'s default returns a kind *word*, so a `step.detail`
> row falling through would have rendered as the literal string `step.detail` — the channel existing
> and showing nothing, which is the failure it was built to fix. It renders the module's keys
> generically; a switch on known keys would be the browser learning one module's vocabulary.
> **`module-uo` needed no change**: the code it already shipped started working. The failure channel
> is unchanged and is still `error` alone.
> **A revert of something gone is spelled `{ ok: true }`**, and §H said `{ ok: true, detail: ... }`
> until Phase 15. `detail` is a real member now, but it is a *diagnostic line*, not the way a revert
> reports success — the success is `ok`, and a module that put its answer only in `detail` would be
> reverting nothing.
---
## G — UO implementation plan: the gap list
Scoped to the capability set the two UO programs demonstrate are worth having. Nothing below assumes
a capability exists.
**Classification key:** ✅ already exists · 🔗 composable from existing · 📦 new module capability ·
🔧 new ServUO plugin capability · 📡 new bridge protocol message · ⛔ not practical / excluded
| Capability | Class | Evidence & notes |
| --- | --- | --- |
| Broadcast a system message | ✅ | `POST /admin/broadcast` → `BridgeAdmin.OnBroadcast`. Behind `AdminWriteEnabled`, length-capped, `actor`-attributed, audited both directions. |
| Town-crier line / in-game news article | ✅ | `POST /towncrier` and `POST /news`. Re-posting an id replaces it, which makes a phase-scoped announcement naturally idempotent. |
| Detect deaths | ✅ | `player.death`, `player.murdered`, `mob.killed`. |
| Detect arrival at a venue | ✅ | `region.enter` with `from`, `to`, `map` and the actor — enough to open a gathering when enough people have shown up. |
| Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. |
| Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. |
| Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. |
| Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. |
| **Participation attribution** | ✅ built | Protocol 6 part b. Presence in a declared area plus kill credit inside it, keyed by character serial, **persisted in the world save** so a restart mid-event does not lose it. The area is a map, a point and a radius rather than a region name — the most specific region containing an event is routinely anonymous. Kill credit goes to every damager standing in the area, not to the killer: a last hit is a poor description of who fought something. |
| Oracle NPC with scripted dialogue | ✅ **built (protocol 7)** | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. Phase 12a: a greeting on approach plus keyword rows, on `Mobile.OnMovement` and `Mobile.OnSpeech`. Built rather than layered on `XmlSpawner2.XmlDialog`, which implements exactly this vocabulary **and an `Action` command-scripting field** — the `[set` this table excludes, one field away from an author. |
| Temporary gate to a venue | ✅ **built (protocol 7)** | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. Phase 12a; the deadline is the SHARD's, so a gate closes whether or not the website is heard from again — and it crosses as a duration, never an absolute time. |
| Temporary decoration lockdown | ✅ **built (protocol 7)** | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. Phase 12a places it from the shard's OWN `Data/Decoration/**/*.cfg` vocabulary, carrying the item id: `Static` alone accounts for 5031 placements under **1992 different graphics**, so a bare type name places the wrong thing. Containers are refused — teardown would delete what a player left inside. |
| Named, hued creatures from an allowlist | ✅ **built (protocol 7)** | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. Phase 12a; the ledger is a PERSISTED shard-side registry, because a spawned creature survives the restart that proves a crier line gone. |
| "Simple" boss variants | ✅ **built (protocol 7)** | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. Phase 12a: an allowlisted type plus hits/damage/stat multipliers, each bounded. |
| **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. **Phase 12b closed it at that one key and built the self-check anyway.** Counted on ServUO 57.4: of 156 non-Bridge `Config.Get` call sites, 82 sit outside a field declaration and all but four of *those* are inside a `Configure()` or static constructor, cached at boot exactly as the field initialisers are. The genuinely live, event-useful reads are this key and vendor bribe decay, which no event would lease. The self-check ships regardless — it exists for the operator whose OWN scripts read config live, and it is what keeps a capability that lies out of the catalog. |
| Lease a property on an existing object | ✅ **built (protocol 7)** | Practical, and an earlier revision was wrong to rule it out. Phase 12b: an allowlist of (type, property) pairs — `MaxCount`, `MinDelay`, `MaxDelay`, `Running`, on both `Spawner` and `XmlSpawner`, which share all four names — addressed by a serial or by an `XmlSpawner.UniqueId`. **`Spawner.Amount` does not exist**; the plan named it and the property is `MaxCount`. The hold is PERSISTED, unlike a config lease's: a spawner is in the world save, so a restart preserves the change and destroys only the timer that would undo it. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state, and this is the first lease a GM can actually drift. |
| Grant an event item | ✅ **built (protocol 7)** | An ordinary action, not a special contract member. Admin-gated, capped by `uo.rewards`, ledgered, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. Phase 12b resolves the RECIPIENTS on the shard, from protocol 6's participation ledger, keyed by the same serials core stores as `member_key`: the website has the list too, but reaching it would have meant a core surface handing a module core's own participants. **This row said failure aborts rather than retries; protocol 6 changed that** — an idempotency key means a retried grant is answered by the original reply, so it is retryable like every other verb. |
| Toggle a ServUO seasonal event | ✅ **built (protocol 7)** | **Corrected twice.** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries — so the verb is a three-state toggle over named events. And it is not "small and safe": the setter fires `OnStatusChange()`, which generates or removes world content for six of them. Safe (ServUO does it to itself from a staff gump), but not small. **Eight of the nine**: `TreasuresOfTokuno` is excluded because `IsActive()` reads its own `DropEra` rather than `Status`, so a lease on it would apply cleanly, read back, restore cleanly and do nothing — §N10's "capability that lies", and the one instance no runtime probe can catch. |
| Trigger a world save | ✅ **built (protocol 7)** | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built until Phase 12b. `AutoSave.Save()`; the `world.save.*` events it emits are already streamed, so the reply says only that the save STARTED. A save stops the world, so it is RATE-LIMITED rather than capped — `Bridge.EventsMinSaveIntervalSec`, counting ServUO's own autosave as the last one, refusing rather than queueing, and answering 429 because it is the one refusal on this plane that waiting fixes. |
| Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. |
| Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. |
| Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. |
**What the reopening actually cost.** Two protocol bumps — 6 for the idempotency key, the lease
deadline and the participation ledger, 7 for the world verbs and the two borrowed planes. (One bump
was the estimate; the guarantee had to land before the verbs that depend on it, which is
[`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is cap-bounded,
ledgered and attributable by construction. **None of it is `[add`.** The distinction that makes it
defensible: an event may **own** what it creates and **borrow** what it changes — and a borrowed
value carries a deadline the game itself enforces. What it may never do is change something with no
baseline recorded, which is exactly what `[set` is.
**One thing the survey changed about "borrow".** A borrowed value whose home is the world save — an
object property, a seasonal status — does not come back on a restart the way a config value does, so
its HOLD is persisted on the shard and its deadline re-armed at load. The fail-safe still holds; it
just has to be written down for the planes where a restart is not itself the restore.
---
## H — Rust / Oxide compatibility
The `rust-dryrun.md` exercise already settled the shape: an Oxide plugin dialling out to a per-server
sidecar, one sidecar per server, the module holding several clients, core never learning there is
more than one. Nothing here contradicts it.
> **The lease is the primitive that travels, not the spawn.** "Double gather rate for the weekend" is
> the canonical Rust community event, and it is *exactly* lease-with-expiry. Convars, spawn population
> multipliers, decay rates, night length, forced events — nearly all of Oxide's surface is
> set-a-value-and-restore. Spawning creatures at a landmark is UO-shaped; holding a value for four
> hours is **every game**. For a platform aimed at private servers, UO first and Rust after, the
> contract should be built around the lease and treat object creation as the special case.
| Concern | UO | Rust via Oxide |
| --- | --- | --- |
| Action verbs | `uo.creature.spawn`, `uo.npc.place`, `uo.gate.open` | `rust.prefab.place`, `rust.zone.open`. **Different verbs entirely, and that is correct** — the contract never asks two games to expose the same actions. |
| **Leases** | live config keys, spawner rates, seasonal toggles — a plugin-verified allowlist | convars, population multipliers, decay and gather rates — live by default, which makes Rust the *easier* case. Same declaration, same deadline-down-the-wire, same drift check. |
| Budget dimensions | creatures, bosses, NPCs, gate-minutes | prefabs-placed, zone-minutes. Core holds `{dimension, consumed, cap}` and never learns either vocabulary. |
| Phase conditions | ~60 telemetry kinds, some polled on a sweep | Oxide publishes real hooks — kill attribution, entity death and proximity arrive as events rather than being diffed. |
| Participation | The hard part | Substantially easier — hooks carry attacker and victim. |
| **Rewards** | An item into a backpack. `reversible: 'none'` — once given it is gone. | A kit, a permission group, currency via an economics plugin, a cosmetic. **Several of those are revocable**, so a Rust reward may be `reversible: 'override'` — a weekend VIP group is a lease with a deadline, not a gift. Core sees the difference as one enum value it never interprets. |
| Several servers | One shard | `run.scope` is in the run's unique key, so one definition fans out to six servers without colliding with itself. Caps are per-run, so a fan-out to six servers is six separate budgets rather than one shared pool. |
| Wipes | Never | Monthly, and a wipe invalidates every ledgered resource for that server at once. The revert contract must accept "gone, and that is fine" as a successful revert, spelled `{ ok: true }`. This row said `{ ok: true, detail: 'resource no longer exists' }` until Phase 15, when `detail` turned out to be a member nothing read; it is a real one now, but it is a diagnostic line beside the answer and never the answer itself (see [§F](#f--the-module-contract)). A wipe also resets leased values to their defaults, a second reason restore must be idempotent. |
| Identity | In-game `[link` code | Steam — still `rust-dryrun` finding 1's open gap. Events neither closes it nor depends on it: `event_run_participants` carries a module-opaque `member_key`. |
**The agnosticism is provable, not merely asserted.** Make `event_definitions.owner_module` nullable
so an event composed only of core actions — announce, cue, wait, publish — runs on bare core with
**zero modules installed**. If the engine runs with no game present, it demonstrably contains no game
vocabulary. That is the same acceptance test the module contract itself uses, and `check:modules`
already fails core's build on a UO identifier, so it is enforced in CI rather than in review.
---
## I — Admin UI, authoring and the calendar
| Screen | Pattern it reuses | What is new |
| --- | --- | --- |
| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. **The public half is Phase 14a's and is a LIST, not a grid** — the same data answering a different question; see below. |
| **Definition editor** — basics, storyline, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. **Finished in Phase 13**, which added the per-phase cap draw, the advance-condition builder and the start dialog — `rehearsal`, `scope` and `params` have been on the start route since Phase 10 and the screen posted none of them. An event whose concurrency key names a `{placeholder}` therefore could not be started correctly by hand at all: the key is rendered from the run's own params, so every manual run rendered the same one and the second was refused as an overlap with the first. |
| **Step editor** — pick an action, fill its params | **The condition builder, exactly**: core serves a catalog, the module declared the schema, core renders a form it does not understand | Option sources; a live cap meter. **Built in Phase 13**, with the JSON box kept as a per-step escape hatch — see below. |
| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. **Built in Phase 6**, `admin` only. Each row says whether it is showing a stored decision or a risk-class default, because "an admin turned this on" and "this has always been on" are different facts and only one is a decision somebody made. A cap may only name a dimension the action actually spends — a cap on one it never names would be a number an operator believes is protecting them while it bounds nothing. |
| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. **Built through Phase 6**, caps included: a meter per dimension rather than a sentence, because unlike a gate a cap is two numbers and a name and needs no grammar rendered to be read. Shown on finished runs too — *"how much did last night's invasion actually spawn"* is the same question asked the morning after. |
**A calendar entry is one of two things, and Phase 4 draws the difference rather than stating it.**
A **run** is a real `event_runs` row: it has a status, a pinned version and a console, and somebody can
cancel it. A **projection** is arithmetic beyond the materialisation horizon — no row, nothing
committed, nothing to open. The API says which each is (`kind`), and the UI draws a projection dashed
and dimmed, because an operator acting on a forecast as though it were a booking would have been
misled by the screen rather than by the server. **A projection is never emitted for an instant a run
already occupies**, which keeps the fortnight inside the horizon from being drawn twice — and which
also means a **cancelled** occurrence does not reappear as a forecast looking like it is still coming.
Filtering by run `status`, or by a named `scope`, suppresses projections entirely: a forecast has no
status, and automatic expansion happens at the empty scope.
**The grid's date axis is the reader's timezone; each entry's time is the event's.** §E gives the zone
to the event because every listing this replaces is written in the shard's local zone — but "what is
happening this month" is a question about the month the person reading is living in. So the cell an
event lands in is the reader's date, and the time beside it always carries the event's own zone
(`20:00 Europe/Berlin`), which misreads as nothing. The server returns UTC instants and does not guess
the reader's zone; the client places them.
**Series are managed on the calendar**, inline, because the calendar is what makes an arc visible in
the first place. Their writes are `admin, editor` rather than `admin`: naming an arc is authoring, and
[§N2](#n--decisions)'s narrow gate is about committing the deployment to a run. A series **delete** is
a real delete — the only one in this feature, where a definition is archived instead. A series pins
nothing and no run references one; `event_definitions.series_id` is `ON DELETE SET NULL`, so its
definitions survive without an arc and re-attaching one is a dropdown. The response says how many were
detached, because that is the entire consequence of the act.
**The phase editor should be a timeline, not a node graph.** A canvas is the obvious thing to reach
for and it is wrong here for a reason that comes out of the code: **the condition grammar has no
branching.** It is `and`/`or`/`not` over comparisons, bounded at depth 5, deliberately closed, and
that is all the expressive power this project has ever handed an operator. A canvas would promise
power the engine does not have. A vertical timeline — phases in order, each with its steps, its
advance condition, its cap draw and its failure policy — says exactly what the engine can do.
**Core renders no game word, and it is already enforced.** Every label in the step editor comes from
the registration: the action's `label`, its params' names, its option sources' labels, its budget
dimensions' labels. `check:modules` already fails core's build on a UO identifier.
### What Phase 13 settled, replacing the two placeholders
Phase 3 shipped a step's params and a phase's `where` as raw JSON boxes, each captioned as a
placeholder for this phase. Both are forms now, and three things about how they give way are
decisions rather than mechanics.
**A form gives way to JSON on the condition builder's own rule.** A value the editor cannot
round-trip is SHOWN rather than silently rewritten — which is why the two placeholders were always
the same problem. Flattening `A and (B or C)` into `A and B and C` changes which firings release a
phase; dropping a param the action does not declare changes what a step does. Neither is visible in
a save that otherwise looks clean. Three things force a step into JSON, and the screen names which:
the action is dormant, so there is no declaration to draw; the step carries a param the action does
not declare, which the save refuses BY NAME and which is exactly what the author needs to read; or a
value no single control can hold. Otherwise the author's own choice of editor stands.
**A `where` the builder cannot draw is posted back unchanged.** The builder offers the flat half of
the grammar — one `and`/`or` over a list of comparisons — and a nested tree opens read-only with its
JSON showing and one honest choice: leave it, or clear it and start again. The alternative to posting
it back verbatim is deleting an author's predicate because this screen could not render it.
**It is the engagement builder, not a second one shaped like it.** The grammar behind a phase gate
IS the engagement condition grammar: `engagement/conditions.js` validates it at save, and the
diagnosis panel's sentence is rendered on the server from the same labels. `conditionRowsFrom`,
`conditionsFromRows` and `coerceLiteral` are imported, not reimplemented. The coercion is the half
worth naming: every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }` against an
`int` variable is refused — rightly — by a server whose error the author would then be reading as a
complaint about JSON rather than about what they typed.
**A boolean param is a three-value select and an empty field removes its key.** A checkbox cannot
say *"not set"*, which for an optional boolean is a real third state — the action's own default —
and would post `false` for every param nobody touched. And `checkParams` reads `undefined`, `null`
and `''` alike, so clearing a required field is answered *"is required"* rather than with a type
complaint about an empty string.
**The searchable source got its consumer here.** Phase 12b shipped `q` and `searchable` and nothing
sent a term — so the one screen that picks a spawner was picking from a 2,000-entry truncation of a
6,707-entry world, with nothing on it saying so. Which control is drawn follows the source's own
`searchable`, never the length of the answer: inferring it from a short list reads correctly right
up until the same source is a dropdown on one shard and a search box on another.
### Four affordances worth building in from the start
- **Dry run.** *(Built, Phase 6.)* Materialise the steps, dispatch each with `verify: true`, report what *would* happen
and what it *would* cost against the caps. The project's own idiom, and what turns an unattended
world write from alarming into routine.
- **The live cap meter.** *(Built, Phase 13.)* What the plan in the author's hands would spend, and
what this deployment allows, updated while they type. **Deliberately not a lighter dry run**: it
dispatches nothing, so it knows nothing a module knows — whether the landmark exists, whether the
shard is up — and the screen says so under the numbers. What it answers is the half core can
answer alone, which is the half a debounce may ask on every keystroke. It says what it does not
know, too: a step core could not price makes every total an under-count, and an author reading a
number smaller than what will happen is worse off than one reading no number at all.
- **Rehearsal.** *(Built, Phase 10; reachable, Phase 13.)* Run for real with announcements ceilinged
to `staff`. Free from the ceilings lattice — the run is real, the world changes are real, no player
is told. The narrowing ceiling on the emit envelope arrived in Phase 10 and the checkbox that asks
for it in Phase 13, which is longer than it should have been: an affordance nothing can reach is an
affordance nobody has.
- **A GM cue step.** A core action whose implementation is "post the instruction, wait for a human to
confirm they did it in-client, then advance". No module, no protocol, no world write — and it makes
the entire system useful on day one, with a GM doing the target-driven parts in-client exactly as
`ADMIN_CONTROLS.md` §8 argued they should. **Both halves exist as of Phase 3**: the action parks
the step in Phase 2, the run console's confirm ends it.
- **Live controls that are honest.** Pause, resume, skip a step, force a phase advance,
cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event
is not one of them (see [Versioning](#versioning-and-editing-a-live-event)).
> **Seven of those exist as of Phase 5, and one does not — for a reason, not for scope.** Pause,
> resume, cancel, and a step's confirm, skip and retry arrived in Phase 3, gated to `admin` +
> `moderator`. **`advance` joined them in Phase 5**, which is when it started meaning something: a
> phase used to advance when its steps went terminal and on nothing else, so "force it anyway" named
> no state an operator could be in, and the per-step skip already did that one step at a time. A
> phase with a gate can wait on a boss that will never spawn, and then it names exactly one.
>
> **Its three refusals are the design.** A run that is not `running` is waiting on nothing. A phase
> with no advance condition already advances on its steps, so forcing it would duplicate the runner
> rather than override it. And a phase whose steps have not all gone terminal is held by a *step*,
> not by its gate — the step-level skip is the honest control for that, one step at a time, and a
> force that swept past pending steps would be a cancel of half a phase under a button labelled
> advance. It satisfies the gate and stops; the next tick performs the phase boundary, exactly as it
> does after `resume`, so there is one implementation of what a phase boundary is rather than two.
>
> **All eight exist as of Phase 8, and cancel gained its `{ cleanup }`** now that there is a ledger
> for it to work over — a flag that changed nothing would have been the "control that answers 200 and
> does nothing" this plan has refused twice already, which is why it waited. It defaults to TRUE:
> §L makes cancelling *without* cleanup the separate, admin-only, logged action, so the safe direction
> is what a moderator's cancel does without having to know the flag exists. `cleanup` itself is the
> eighth control, and the one gated NARROWER than the rest — see [§K](#k--security-model).
> **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a
> paused run is paused *at* that step — so re-queueing without resuming would leave the run in
> precisely the state it was already in, with a second button the operator now has to find.
> Splitting them would read as honesty and behave as a trap. The single action re-queues the step and
> resumes the run, and its log line says both.
>
> Two guards make it safe. The step must be `failed`, and it must be **the furthest step its phase has
> reached** — `MAX(seq) WHERE status <> 'pending'`. The near miss is worth recording because the
> obvious rule is the wrong one: "the lowest step that is not settled" looks equivalent and is not,
> because `nextOpenStep` selects `pending` and `running` only and the runner therefore steps *over* a
> failed step. Under that rule a phase whose second step failed-and-skipped and whose fifth then
> failed-and-paused would offer retry on the second, re-queueing a row behind the runner's own cursor
> where it would sit `pending` for ever.
>
> `attempts` returns to zero, and that is not the rule Engagement Phase 14 arrived at being broken.
> That rule is about *sweeps*: an automatic path that reset a counter made the ceiling unreachable and
> the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named
> person deciding once is the thing it is unattended from.
**The diagnosis panel** (Phase 5) sits above the step list for the same reason the parked cue does:
a phase waiting on a condition is `running` and looks completely healthy, and the one screen an
operator opens to find out why nothing is happening must say so before they have to read a log. It
carries the clause in the condition builder's own words, the tally, how long the phase has waited,
and the **last related firing whether or not it counted** — because *"the boss did spawn, in
Britain"* and *"no boss has spawned"* are different answers that look identical without it.
**The sentence is rendered on the server**, and this is the one place this feature does not hand the
client a vocabulary to render. `gates[].where` arrives as text. The labels are defined in
`engagement/conditions.js`; a renderer in the browser would be a second implementation of a grammar
the server owns, and the first clause the two spelled differently would meet its operator at two in
the morning. Only the variables the condition **names** are recorded on the gate row and shown — not
the payload — because that row is read back onto an admin screen and a copy of a whole game event's
data would be a second copy of exactly the content `engagement_sends` is careful not to keep.
### What Phase 14a settled about the public surface
An upcoming-events calendar with series and arcs, a live-status page, and published results —
**built**, at `/site/events`, `/site/events/:slug` and `/site/events/series/:slug`, over the three
`/public/events` routes and `GET /player/events/history`.
**A visitor's calendar is a list; an operator's is a grid.** That is not a styling choice made twice.
An operator's question is *"what does this month look like"* — coverage, clashes, the gap on the third
weekend — which a grid answers and a list answers by making them count. A visitor's question is
*"what is on, and when is the next one"*, which a chronological list answers at a glance. The
projection/run distinction survives into the public list unchanged, dashed and labelled *expected*,
for the visitor's version of the operator's reason: past the horizon nothing has been committed to,
and a page drawing a forecast identically to a booking would be promising on the server's behalf.
**What is public is `listed` AND `ready` AND not a rehearsal, and all three are in SQL.** `listed` is
a new column on `event_definitions`, defaulting to 1, and it exists because publishing is what makes
a definition RUNNABLE: without it, a surprise invasion would have to be advertised a fortnight in
advance in order to be allowed to happen. It is **not** a second answer to the question `state`
answers — `state` says schedulable, `listed` says announced — and an unlisted event still schedules,
still runs, and is still on the admin calendar. Filtering in JavaScript after the read would work
exactly as well, right up until the first caller that forgot; a draft, an archived definition and an
unlisted one all answer 404, indistinguishable from a slug that never existed.
**The public shapes are a projection, and the projection is the security boundary.** Nothing is
spread; a field reaches a public entry because a line in `eventPublic.model.js` put it there. The day
somebody adds a column to `event_runs` — a claim token, an operator's note, a last error — a
`{ ...run }` would publish it in the release after the one anybody reviewed. Three things are
deliberately absent from every public shape:
- **The spec.** Phases, steps, actions and their params are the plan for changing a live world. A
visitor is told what is happening and when, and the **label** of the phase while it is happening —
resolved from the version the run PINNED, so an edit since does not relabel a run in flight. A
phase the pinned spec does not name renders nothing rather than a slug.
- **Health, cleanup, claims and errors.** A run with `health: 'degraded'` renders as running, which
is the tolerance this section already required — "the shard is having trouble" is operator
information, and the shard pages already model exactly this by rendering last-known state.
- **`member_key`.** It is the game's own identifier for a character and it is module-opaque, so core
cannot say what publishing one would disclose. A results row shows whatever display name the module
put in `meta`, or nothing.
**`failed` and `missed` are both published as `cancelled`, and `paused` as `live`.** To a visitor the
first two are one event — it was on the calendar and it did not happen — while the difference between
them is entirely about the deployment: `failed` names broken machinery and `missed` names a process
that was down when the schedule came round. Publishing either word would tell a stranger something
true about the server and nothing about the event. `paused` is the same argument in the other
direction: an operator holding a run for two minutes is not a state a public page should render, and
one that said "paused" would invite a question whose answer is internal.
**The six public triggers gained `eventUrl` here, which is the version bump this file promised.**
Until Phase 14a there was no page, so they declared no url at all — `news.post` had already paid for
that mistake once, previewing a link in the template editor that was dead in every mail it sent. The
variable carries `?run=`, and the query string is the whole reason it is a run url rather than an
event url: the page lives at the definition's slug, so a weekly event has one stable address that
survives a retitle, but every one of those triggers is about one OCCURRENCE. It is **optional**, and
`email.button` drops itself when its url interpolates to nothing — so an unlisted event still mails
correctly, with the block absent rather than rendering an inert grey label. `event.run.failed` gains
nothing: an admin reading that the machinery broke wants the console, not the storyline.
**Core publishes an `events` capability string**, in the `version` block that `GET /public/version`
returns and `GET /public/status` embeds. It is the same idea as a module's `capabilities` on
`/public/modules` and deliberately the same word — but a separate list, because core is not a module
and a client must be able to tell *"this backend has events"* from *"a module called core is
installed"*. **The value is in what is absent**: a backend released before Events omits the key
entirely, which is a distinction probing `/public/events` cannot make, where "not built" and
"temporarily down" look alike.
**A series with no listed events is a 404 rather than an empty page.** An arc is a label on its
definitions and nothing else, so a page for an empty one would publish the single fact that an
operator has named something they have not announced.
**Participation history is mounted twice, and it is the third screen to need that.**
`GET /player/events/history` is behind `requireAuth` alone and self-scoped on `req.user.id` — staff
are a superset of players, so a staff member has an attendance record like anyone else. The WEB is
what disagrees: `RequirePlayer` sends anyone who is not a `player` out of `/account`, so a single
mount there is a screen the reviewing admin can never open. The notifications pair hit this exact
wall in engagement Phase 7 and answered it with two routes, one pair of components and one mapping
in `lib/notificationPaths.js`; `eventHistoryPath` joins `inboxPath` and `notificationSettingsPath`
there rather than starting a second file with the same comment at the top of it. The staff path is
`/admin/events/mine`, in the Events section of the sidebar, and it is the one row in that group
with no `roles` — every account has a participation history and the server scopes it to the caller.
> **Three defects, and the live walk is the only thing that found any of them.** Both suites were
> green before it and after each fix. Two were the same mistake in different files — a split reading
> a STATUS where it should read a clock: the calendar told a visitor an event four days away *did not
> happen* (it had been cancelled, and the tense of that word follows the clock, not the status), and
> the event page filed that same future occurrence under *previously*. The third was the mount above.
> A cancelled occurrence still belongs under what is coming, because *"next Friday is off"* is
> exactly what somebody checking a calendar came to find out.
> **A venue was never a field, and Phase 14a stopped saying it was.** §I's screens table and the API
> surface table had both described a venue since the first revision — the definition editor's
> sections, and what an event page shows. There has never been one: not a column, not a key in
> `spec`, not an input on P13's form, not a string anywhere in either repo. Rather than add a field
> on the way past to a public page, both descriptions dropped it; the storyline already carries the
> where. (The word still appears in §G and §B2 as ordinary English for a place in the world, which is
> what it always meant there.)
---
## J — Integration map
| System | How Events connects | Status |
| --- | --- | --- |
| **Engagement** | Core registers triggers under `event.` — `run.scheduled`, `run.started`, `phase.changed`, `run.ending`, `run.completed`, `run.cancelled` at ceiling `authenticated` / default audience `subscribers`; `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN, so a weekly event is not throttled by last week's. **`run.started` is also a notification STREAM**, and it is the only one: push delivery is stream-keyed, so a trigger that is not one can be mailed and inboxed but never pushed. Events owns **none** of the delivery. **Built in Phase 10**, with two rules seeded — `run.started` and `run.failed` — and both, like every seeded rule, off. | built |
| **Scheduled triggers** | The event runner is the natural home for the `kind: 'scheduled'` evaluator ENGAGEMENT §7.1 Q6 deferred. One tick, two consumers, and it closes an open item rather than adding a second timer. | recommended |
| **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. **Built in Phase 10**, on a nullable `announce_jobs.run_id`: a post may now have more than one job, and everything that means "the post's job" — the admin panel, its retry button, `announced_at` — still means the news one. | built |
| **Notifications** | Falls out of Engagement — email, in-app inbox, content-free push tickles, per preference, with suppression and the verification gate. Nothing new. | exists |
| **Teams** | "This Team's members" is already a registered audience, so a guild-scoped event is a rule's audience segment and needs no event-side feature. | free |
| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; **the surface that reads it was built in Phase 14a** — `GET /player/events/history` and `/account/events`, self-scoped, with the calendar's own two exclusions so attending an unannounced event does not disclose that it exists. The `member_key` is not published even there: the caller is a player reading their own page, and the key would add a module-opaque string nothing on it can render. | built |
| **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication |
| **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module |
| **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out |
| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half was built in Phase 14b** (`android-app` M13), with its prerequisite: 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 is installed and wrong for one that is not; the fix gates on a capability string rather than on a module id, and its governing rule is that **absence of an answer is not an answer of absence** — a successful module list that omits the string hides the rows, a failed read keeps the last answer, and a host that never answered leaves the gate open. Core's `events` gates the two event rows. The app's four screens are the calendar, an event page (carrying `?run=`), an arc and participation history — the last as **one** row for any signed-in account, because the route is `requireAuth` alone and self-scoped and only the web's own `/account` guard needed a second mount. | built |
| **Admin audit** | `ctx.activity.log` for every edit, publish and manual transition. The runtime record is `event_run_log`, because `activity_log.detail` is `TEXT` and cannot be queried by phase or step. | exists |
---
## K — Security model
A module capability and permission to invoke it are different things. Today a module's route is gated
by `requireRole` and nothing else — adequate for reading a roster, not for a staff member changing a
live game world on a schedule.
### Three layers, and the role check is the gate
1. **Declaration.** A module says a verb exists, what it costs, and what risk class it is. This is
code the operator installed; it is not a permission.
2. **Enablement.** An admin enables an action for this deployment and sets its per-run caps.
**Nothing that CHANGES THE WORLD is enabled by default** — `change` and `irreversible` arrive off,
`notify` and `inspect` arrive on — the posture a seeded engagement rule already takes, arriving
`enabled = 0` because installing a module must never start doing things.
> **This line said "nothing above `notify`" until Phase 6 built it** (org lead, 2026-09-03). Read
> literally it shipped `core.wait` — which is `risk: 'inspect'` — disabled, so every published
> event that waits would break on a fresh deployment until an admin found the switch. An `inspect`
> action reads state and writes nothing, so a deployment gains no risk by having it on. **The same
> line is the role floor below**, and it moved with it.
A missing row is not "disabled": it is *the default for the action's risk class*, computed rather
than stored. Nothing is seeded at boot, and that is structural rather than tidy — registration runs
under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API §2.2), so a boot-time
seed of one row per registered action would be exactly the database write those two forbid.
3. **Invocation.** `requireRole`, exactly as every other admin capability is gated, plus the cap check
and — for anything reaching the game — the shard's own `AdminWriteEnabled` and
`AdminAccessFloor`. Admin routes are already re-validated against the database on every request, so
a demoted user loses access immediately. **A run already in flight is the one place that
re-validation deliberately does not reach** — see [§E](#e--runtime-model).
The shard's own switches are named as a layer and **not duplicated in core**. They live on the
shard host, outside the website's reach entirely, and a second copy of that decision in core would
be a copy that could disagree with the shard about whether the shard is accepting writes. A module
honours them when it translates an action into a sidecar command (P9). Naming the layer is what
stops it being re-implemented.
### Which role, per surface
Following the split module-uo already uses — `requireRole('admin','moderator')` for live operations,
`requireRole('admin')` for configuration that can break things — **with one deliberate departure,
decided in [§N2](#n--decisions): starting a run is `admin` only, while stopping one is not.**
| Surface | Role |
| --- | --- |
| The calendar, run history, the run console, the diagnostic log | `staff` — the tier gate, nothing added |
| Authoring and editing a draft | `admin`, `editor` |
| Publishing a version, **starting a run** | `admin` only |
| **Live control of a run in flight** — cancel, abort | `admin`, `moderator` |
| Any step whose action **changes the world** (`change`, `irreversible`), the action switchboard, and **re-running cleanup** | `admin` only |
> **Why start and stop are gated differently.** Starting commits the deployment to everything the
> definition contains, unattended, up to every cap it declares — it wants the narrowest gate there is.
> Cancelling is incident response, and the incident is "the event is doing something wrong at 2am" —
> it wants the widest. A split that read consistent, with one role owning both buttons, would behave
> badly in exactly the case the moderator role exists for.
> **Keep the check in one function.** The whole authorisation decision — role, enablement, cap, shard
> switch — should live behind a single `mayInvoke(user, action, run)` rather than being spread across
> route middleware. Not for tidiness: it is what makes an EM-style delegation model a *later* option
> rather than a redesign. If a deployment ever wants named coordinators with their own budgets, that
> is one function learning to consult a second table, and nothing else in this document changes.
**As built** (Phase 6, `events/authorize.js`). It answers an envelope rather than a boolean — every
refusal carries a `code` a caller branches on and a `reason` a human reads, written in one place so
the dry run, the editor, the run console and the log all say the same sentence about the same fact.
Four callers: authoring a step, publishing, the dry run, and the runner's own unattended dispatch.
Three things about it are not obvious from the signature:
- **`user` may be null**, and that is the unattended runner rather than the system escalating. The
role layer is skipped, for the reason given in [§E](#e--runtime-model).
- **`run` may be null**, and the cap question changes when it is. With a run, the question is "does
this fit in what is left"; without one — the editor, the dry run — it is "could this cost *ever*
fit", which is an authoring error answerable before anything is scheduled.
- **The cap check can WRITE.** The cap is held by a conditional `UPDATE` whose `WHERE` carries the
guard, so checking and then spending would be two statements with a race between them — the exact
race the conditional increment removes. `spend: true` is therefore a parameter rather than a second
function: one decision procedure, one set of layers, and the authoritative check is the one that
also commits.
**The role floor is checked in the model, not in route middleware**, and it is the one gate that
could not live there: the route is `admin, editor` and stays that way, while *which of the two you
have to be* depends on what is in the body. Checked at save as well as at publish — the difference
between telling an editor now and telling them after they have written twelve steps.
**A module's `cost()` is called by core and nowhere else.** It is declared as a function of params
([§F](#f--the-module-contract)) and called with the params the step actually carries, so the number
core enforces is the number the module said. A `cost()` that throws, or answers anything but a flat
object of non-negative finite numbers, makes the action **unpriceable and therefore refused** — never
free. An action whose own accounting is broken is not an action whose consumption is zero.
> **[§F](#f--the-module-contract)'s `registerEventBudgets` landed in Phase 7 and replaced half of
> this.** A dimension's id, label and unit are now declared, so the switchboard no longer invents a
> name for a cap box — and a `cost()` naming a dimension nobody declared is refused outright, at save,
> at the dry run and at dispatch. What a registry cannot answer is *which* dimensions an action
> spends, because `cost` is a function of params, so core still discovers those by pricing the
> declared `example` values. That is a use every param's required `example` already has, it costs
> nothing, and it is honest about its limit: a `cost()` returning different dimension *keys* for
> different params under-reports, which costs an operator a cap box on the switchboard and costs a run
> nothing at all — a run's budget is seeded from the params its steps were actually authored with,
> never from examples.
### Can arbitrary module commands be injected through the browser?
**No, and the design must keep it that way.** The browser posts an action *id* and a params object;
core validates the id against the registry and every param against the declared type before anything
is dispatched. There is no passthrough field, no raw command string, and no place a body can name a
sidecar route. It mirrors the existing write plane, where the browser never sends `actor` — the
controller stamps it from the session.
### What a compromised account can and cannot do
| Bounded by | How |
| --- | --- |
| Per-run caps | The strongest control, and the reason to keep quotas after dropping delegation. A stolen admin session cannot exceed 30 creatures per run because the cap is enforced in a conditional `UPDATE`, not in a role check — and a role check is exactly what a stolen session has already passed. |
| Enablement defaulting off | Nothing that changes the world is invocable until an admin turned it on deliberately. |
| The shard's own switches | `AdminWriteEnabled` is off by default in `Bridge.cfg`, lives on the shard host outside the website's reach, and `AdminAccessFloor` refuses any target at or above CoOwner. |
| The ledger | Every world write is recorded with its run, step and actor, and every ledgered resource has a declared revert. Damage is attributable and, by construction, undoable. |
| Dry run before anything unattended | **Built and enforced in Phase 6, for SCHEDULED starts only** (org lead, 2026-09-03). A version records who verified it and when; the scheduler refuses to materialise an occurrence of one that never passed, and says so once per definition per tick rather than silently. A human pressing *Start now* is not gated, because that human **is** the review the gate exists to require. A failing dry run records nothing, so it cannot unlock anything. |
> **Stated plainly.** The module boundary is **not** a security boundary — MODULE_SYSTEM §2.2 says so
> explicitly, and a module runs in the same Node process with full access. None of the above defends
> against a hostile module; it defends against a compromised session and an operator mistake. Both are
> made larger by *scheduling* — a change that happens while nobody is watching — which is why the caps
> and the leases matter more here than the role check does.
---
## L — Failure and recovery
| Situation | Behaviour |
| --- | --- |
| **A step retries at all** | The run goes `degraded` on the FIRST retry, not on the eventual failure — an event whose announcements are landing on the second attempt is having trouble now, and now is when an operator wants to know. `health` is not `status`: the run is still genuinely running (§E). |
| **Core restarts mid-run** | Nothing is held in memory. The next tick finds steps in `running` with expired leases, reclaims them *without resetting `attempts`*, and continues. A step whose ack was lost is re-dispatched with the *same* idempotency key. |
| **Core is down when a run should start** | Within `grace_seconds` it starts late and the log says so. Past it the run is `missed` — a terminal state a human can see. An event that begins three hours after its announcement is worse than one that visibly did not. |
| **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. |
| **The sidecar is unreachable** | Every client call returns `{ ok: false }` rather than throwing — `uoLinkClient` never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state. |
| **The module is disabled or fails to boot** | `installed_modules` records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails `terminal` with the module named, and the run degrades rather than claiming success. **Never a silent skip.** |
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe*, and as of protocol 6 (Phase 11a) it exists: proved on the rig with three broadcasts under two keys producing **two** rows in the shard's own audit trail. It has one bound worth knowing — the plugin remembers a key for an hour, capped at 4096, and says so loudly if the cap ever evicts a live one. |
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. It then takes the step's own `on_failure`, so a `change` step's default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes (org lead, 2026-09-03). Health goes `degraded`. |
| **A step names an action this deployment has disabled** | The same `refused`, in the same words as a cap breach, with the reason naming the action by its label. It is not an authoring mistake — the plan was legal when it was written — so the switchboard is the fix, and a disabled action reached by a run in flight is exactly the case the dry run catches beforehand. Phase 6. |
| **A scheduled occurrence of a version nobody dry-ran** | Not materialised at all, and logged once per definition per tick rather than once per occurrence — there is no run row to hang a diagnostic on, which is the point. The definition itself carries the fact, so the editor says so where its author is already looking. §K, Phase 6. |
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); the ledger is then swept by the runner's cleanup leg on its next tick — the request does not wait for it, because a cancel pressed at 2am must answer at once rather than after a round trip per resource to a shard that may be why it is being pressed. Cancelling *without* cleanup is a separate, logged, admin-only action (`{ cleanup: false }`), and it leaves the run `cleanup_status: 'incomplete'` with every unreverted row on the console — the truthful value, because the world changes really are still up. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
| **A human skips a step** | `skipped`, which is what that status is reserved for. Legal for a `pending` step and for a parked cue; refused for a step with a live lease, and unnecessary for a `failed` one — `nextOpenStep` already passes over it, so resuming the run carries the phase past it. |
| **A human retries a step** | Only while the run is `paused`, and only on the furthest step its phase has reached. `attempts` returns to zero and the run resumes in the same action; both facts are in the log line with the actor. |
| **Cleanup itself fails** | The run reaches `completed` with `cleanup_status = 'incomplete'`, the unreverted resources listed and a manual retry offered. It does **not** stay `running` — an event whose world changes are still up is a real state, and pretending the event is in progress hides it. The automatic sweep stops after `EVENT_REVERT_MAX_ATTEMPTS` and leaves the rows for a person; `POST …/cleanup` is the only thing that clears the counter, with the actor logged. |
### The ledger's two rules
**1. A resource is recorded before it is confirmed.** The step writes a `pending` row, dispatches, and
promotes it on the answer. Recording *after* makes every object whose ack was lost invisible to
cleanup forever. The cost is a `pending` row for something that may not exist — so reverting a
non-existent resource must be a success, which is also what a Rust wipe needs.
**2. Revert is idempotent, and its failure is loud and sticky.** A revert that runs twice must succeed
twice. One that never succeeds stays visible until a human acts, because the alternative — a tidy
`completed` row over a shard full of orphaned monsters — is the failure that would end this feature's
credibility on its first bad night.
### What Phase 8 settled about the ledger
**Rule 1 needed a mechanism, because a spawn's `ref` does not exist until the module answers.** What
goes in before the dispatch is a PLACEHOLDER keyed by the step's idempotency key — the reserved
`kind: '@step'`, `ref` = that key — and the reported resources are inserted `confirmed` on the answer,
resolving it. A dispatch that never answers leaves the placeholder standing, and cleanup then calls
`revert({ idempotencyKey, resources: [] })`. **That is why [§F](#f--the-module-contract)'s `revert`
takes the key at all**, and it is the case rule 1 exists for: record afterwards instead, and a
timed-out spawn is twelve creatures no cleanup will ever see. The placeholder is written AFTER the
permission check and BEFORE the module is reached — a refused step created nothing and must ledger
nothing.
**A LEASE does not use the placeholder, and gets rule 1 in a stronger form.** Its target is knowable
before the dispatch — it is the lease id the step names — so `core.lease` writes the real row first.
That is also the only moment the two-events-one-target refusal can happen *before* the world has been
written to; a second run asking for a held lease comes back `refused`, in the same words a cap breach
uses and for the same reason.
**Recording is idempotent because the database makes it so.** A retry re-sends the same idempotency
key and a module may honestly re-report the same resources; `uq_evres_target` refuses the second
insert and core reads that as "already recorded" rather than as an error — `materialisePhase`'s
INSERT IGNORE posture, one table along.
**The unique key is held by three statuses and released by three** (amended 2026-09-03). The row
above said *"among non-reverted rows"*, which was written before the six statuses had their meanings;
taken literally it makes `drifted` and `orphaned` hold a target for ever, so one bad night would
disable a lease permanently with no control able to clear it. `pending`, `confirmed` and `reverting`
mean core still believes the resource is this run's, and hold the target. `reverted` gave it back,
`drifted` means somebody else has hold of the value and this run has deliberately let go, `orphaned`
means it vanished — none of the three is a claim, and all three stay LOUD by the other mechanism:
`cleanup_status = 'incomplete'` and a row on the run console, which is what rule 2 actually asks for.
MariaDB has no partial index, so the encoding is a STORED generated column that is NULL for the last
three; it reads `status` ALONE, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
column is a base column of a stored generated column (error 1901) and `step_id` must stay SET NULL —
the same correction `TEAMS.md` §2.5 had to be given.
**Cleanup is one sweep, not a set of synthetic steps.** The tempting reading of *"cleanup steps are
generated from the ledger"* is a synthetic phase of real `event_run_steps` rows, so the console's
per-step retry comes free. It is the wrong shape here for a concrete reason: `event_run_resources`
already carries `revert_attempts` and `last_error`, so synthetic steps would put a second retry
counter beside the first and the two would disagree the first time a step reverted three of its four
resources. The manual retry is a route over the ledger — `POST /admin/events/runs/:runId/cleanup` —
which is what the § API surface already promised.
**It runs from ONE place: a fifth leg of the runner's tick.** Hooking each terminal path instead
would be four call sites, three of them inside a request, and none of them would survive the process
dying mid-teardown. The leg is ordered AFTER advance, so a run that completes in one tick is torn
down in the same one. `cancel` therefore answers at once and does not wait for the teardown — which
is also the right behaviour for a control pressed at two in the morning against a shard that may be
the reason it is being pressed.
**What that leg SELECTS cost two live-walk defects, in opposite directions, and the pair is the
finding worth keeping.** Keying the scan on `cleanup_status = 'pending'` is the obvious reading of
the column, and it was wrong twice on the same boot:
- A run whose only resource was a **lease** never went through the ledger's dirty-marking at all —
`core.lease` reserves its own row rather than reporting one — so the column stayed `not_required`,
the leg never looked at the run, and **the lease was never given back**. The scan now includes
`not_required`: a terminal run with an unresolved row has work to do whatever any summary column
says, and treating that combination as work is the fail-safe direction.
- A run whose first sweep FAILED was moved to `incomplete` **by that very sweep**, so it was never
picked up again and `EVENT_REVERT_MAX_ATTEMPTS` quietly meant one attempt rather than three. So
`incomplete` is now written only once there is nothing left to try — it means *"finished with, and
not finished"* — which is also what makes it the honest marker for a run an admin cancelled
**without** cleanup.
Both are the same mistake: deriving *"is there anything to do"* from a summary column instead of from
the rows that are the fact. Neither was visible to a unit test, because a test that calls the sweep
directly never asks what would have selected the run.
**And the first fix for the second one made the console lie.** Spending every row's
`revert_attempts` was a tidy way to take a `cleanup: false` run out of a scan bounded by the counter
— 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. **A counter that means two
things is a counter a screen cannot render**, so the status column carries that decision instead.
**Only a human clears `revert_attempts`.** The automatic sweep bounds itself at
`EVENT_REVERT_MAX_ATTEMPTS` and then leaves the rows for a person; the cleanup route resets the
counter and asks again, with the actor on the log line. This is Engagement Phase 14's rule stated a
third time — a sweep that returned every stale row to its start state made the attempt ceiling
unreachable, so the row cycled for ever and was never eligible for any retention sweep — and the
licence a human has here is the same one their step retry has.
**Drift is a third outcome, not a failure with a flag.** The module did exactly what it was asked and
found somebody else's value in place, so `{ ok: false, drifted: true, current }` produces a `drifted`
row rather than a retry: asking again would find the same thing, and writing anyway would silently
revert an operator's manual fix. And **`orphaned` is only ever reached through reconcile**, never
through a revert — a revert that finds nothing there is a SUCCESS (§L, and what a Rust wipe needs),
whereas a resource the module reports missing is a thing that vanished while nobody was looking.
Those are two different sentences to the operator reading the console the morning after.
---
## Versioning, and editing a live event
The codebase has a consistent opinion here: it separates identity from presentation and refuses
in-place mutation of the thing an audit refers to. A Team's `name` is immutable and a rename archives
the row; an engagement template carries `seed_version` and `customized` so a better default can arrive
without stealing an edit.
| Edit | Effect |
| --- | --- |
| Editing a `draft` | Free. No version exists yet. |
| Publishing | Snapshots the whole spec into an immutable `event_versions` row and points `current_version_id` at it. The spec is **re-validated against the registries as they stand at that moment**, not trusted from the save that wrote it: a module uninstalled in between must block the publish rather than produce a run that fails at dispatch with the world half-changed. |
| Editing a `ready` definition with no live run | Creates the next version on publish. Future runs use it. |
| Editing while a run is live | Creates the next version. **The live run keeps the version it pinned** and is unaffected. The editor says so. |
| Changing what a *running* event does | **Not an edit.** The live controls are pause, resume, skip, force-advance and cancel — each logged, each attributable, none mutating a version. Anything more expressive is a cancel and a new run, because a half-executed spec edited mid-flight is neither reproducible nor auditable. |
---
## API surface
Following existing conventions: `/api/v1`, tier gates at the tier index, one capability router per
file, `#swagger.*` annotations beside each route, and a zero-line `routes.manifest.json` diff proving
no URL moved.
| Route | Gate | |
| --- | --- | --- |
| `GET /admin/events` | staff | definitions, state, next occurrence, health |
| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads |
| `POST /admin/events` | admin, editor | create a draft |
| `PUT /admin/events/:id` | admin, editor | edit the draft spec |
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
| `DELETE /admin/events/:id` | admin | archive — never a hard delete while runs reference it |
| `GET /admin/events/:id/versions` | staff | version history |
| `POST /admin/events/:id/verify` | admin, editor | **dry run** (Phase 6) — dispatch every step with `verify: true`, report the findings and the whole-plan cost against the caps. Verifies the PUBLISHED version of a `ready` definition and the working spec of a draft; a pass against a version is recorded and is what unlocks scheduled starts (§K) |
| `POST /admin/events/price` | admin, editor | **the live cap meter** (Phase 13) — price the spec IN THE BODY against the per-run caps. Dispatches nothing and records nothing, which is what separates it from the dry run and what makes it safe to call on a debounce while an author types; the spec is in the body because the plan being priced is unsaved between keystrokes. It therefore knows nothing a module knows — whether the landmark exists, whether the shard is up — and the screen says so under the numbers. A step it cannot price is reported in `unpriced` rather than counted as free; an **undeclared dimension is still counted**, because the action really will try to spend it and it is the enforcement rather than the amount that is missing. Over the caps is a `200`, for `verify`'s reason |
| `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` — and optional `params`, which **Phase 13 was the first thing to send**: the concurrency key is a `{placeholder}` template rendered from the run's own params, so a UI posting an empty body rendered the same key for every manual run and had the second refused as an overlap with the first |
| `GET /admin/events/runs` | staff | run history across definitions |
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
| `GET /admin/events/runs/:runId/log` | staff | the diagnostic log |
| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }` and, since Phase 8, `{ cleanup }` — **defaulting to true**, so §L's *"cancelling WITHOUT cleanup is a separate, logged, admin-only action"* is the flag that has to be asked for. `cleanup: false` is refused to a moderator in the MODEL rather than in middleware, because which of the two roles you have to be depends on what is in the body. Either way the request answers at once: the teardown is the runner's cleanup leg, not this call |
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force the current phase past its advance condition (Phase 5). Legal only while the phase is genuinely waiting on its gate; `409` naming what it is waiting on otherwise. Satisfies the gate and stops — the next tick performs the boundary |
| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | Phase 3. `confirm` resolves a GM cue step; `retry` re-queues the step a paused run is stopped at and resumes it |
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources (Phase 8). **`admin` rather than admin+moderator, unlike the seven live controls beside it**: this is not incident response, it asks core to write to the world again, which §K puts in the same row as the world-changing actions themselves. Legal on a TERMINAL run only — a run still in flight has a ledger that is still growing. It is the only thing that clears `revert_attempts`, and it answers **200 whatever it found**, for the dry run's reason: some resources may still be out there, and a 4xx would make that indistinguishable from a bad run id |
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions — and, since Phase 5, the **trigger** catalog and condition operators the advance form renders. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step, and four requests to draw one form would be four chances to render half of it. Served here rather than borrowed from `/admin/engagement/triggers` because that route is `adminOnly` while a definition is authored by `admin` **and** `editor` |
| `GET /admin/events/catalog/options/:sourceId` | staff | **the values behind a param's `source`** (Phase 7) — resolved by the module that registered it, on its own request rather than inside the catalog, because a source can be slow or down and must not take the catalog with it. A refusal is a **`200` with `ok: false` and a `reason`**, deliberately: the field degrades to free text with a warning rather than the form breaking |
| `GET /admin/events/series` | staff | the arcs a definition may belong to |
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) |
| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen |
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps (Phase 6). `admin` on the read as well as the write; the PUT takes one action at a time |
| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. A run is an INTERVAL, not an instant: an entry is in the window when the run OVERLAPS it, so one that began before the window and has not ended is still "what is on" (Phase 16a — reading the start instant alone made this route serve only the first of its three words, while the event's own page said `live`). Defaults to seven days back through 31 days out — the tail is where "recent" lives — and the window may span at most 92; the anonymous surface is the one with no login in front of it. Projections are forecast from NOW, never into the tail, since a slot the runner has already passed did not happen |
| `GET /public/events/:slug` | — | **one event** (Phase 14a): storyline, arc, what is live, what is next, what happened recently, and a results table once one is published. Takes an optional `?run=`, which is what an announcement's link carries, so a mail about last Friday's occurrence does not open next Friday's; a run belonging to some other event is **ignored rather than refused**, because a stale link in a months-old mail should land on the event it was about. A draft, an archived definition and an unlisted one all answer 404 |
| `GET /public/events/series/:slug` | — | **the arc** (Phase 14a). A series with no listed events is a 404, not an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced |
| `GET /player/events/history` | auth | **this account's participation** (Phase 14a) — the run, when it was, the score a module reported, and the rank once results were published (null until then, which is a real state rather than an error). Self-scoped on the session with **no id parameter**, deliberately: a route that took one would be a middleware mistake away from publishing who attended what. Keyset-paged on the participation row's id. It obeys the calendar's two exclusions, so attending an unannounced event does not disclose that it exists |
> **Publish and start are `admin`; cancel is not** — that asymmetry is deliberate and decided in
> [§N2](#n--decisions). `verify` sits with `admin, editor` rather than with `start`: a dry run
> dispatches nothing, and the author who wrote the definition is exactly who should be able to price
> it against the caps before asking an admin to publish it.
> **Two rows above were added by Phase 1 rather than decided in §N**, and both are derived from
> what the surface needs rather than from a new policy. `GET /admin/events/:id` exists because the
> list route serves a summary and the editor needs the whole authored tree; `GET
> /admin/events/series` exists because a definition carries `series_id` and a form cannot offer a
> value it cannot enumerate. Both are staff reads of data the list route already exposes, so neither
> widens the surface's reach.
**What Phase 1 built, and what it deliberately did not.** Definitions CRUD, publish, archive, the
version history, the action catalog, the series read and the run reads are live. Every route that
acts on a run *in flight* — pause, resume, advance, cancel, step skip/retry/confirm, cleanup — is
absent rather than stubbed, because nothing is in flight until the runner exists: a control that
answers `200` and does nothing is worse than one that is not there. `verify` and `GET/PUT
/admin/events/actions` are absent for the same kind of reason — there are no caps to price against
and no switchboard to serve until the phase that builds them.
**Phase 2 added no routes at all.** It is the runner, and a runner has no surface: a published
definition started through `POST /admin/events/:id/runs` now actually runs, and the run reads that
already existed render it moving. The controls above are still absent, and they are still Phase 3's —
the shipped demo of Phase 2 is a run that announces, waits and completes without anyone touching it,
which is exactly the thing that needs no control. `core.cue`'s confirm is the first of them that has
something to act on, and it arrives with the console that shows the cue.
**Phase 3 added six routes, and they are the live controls.** `pause`, `resume` and `cancel` on a
run; `confirm`, `skip` and `retry` on one of its steps. All six are `admin` + `moderator` — the one
gate in this feature wider than `admin`, and deliberately so (§N2). Every one of them is a
compare-and-set against the status it is allowed to act from rather than a read-then-write: the
runner ticks every fifteen seconds, so a console rendered thirty seconds ago describes a run that has
since 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.
**Phase 7 filled the option-source route, and Phase 8 filled the last one. Nothing in this table is
absent any more.** The option-source route answers a refusal with a `200`, which is the one thing
about it worth stating twice: §F requires a source that cannot answer to degrade its field rather
than block the form, and a status code is how a client tells the two apart.
**Phase 10 added no route at all.** Its whole surface is two more derived fields on a run: `participants`, who took part as a module reported them — `memberKey` module-opaque, `userId` only where the module could link the player, `rank` null until results are published — and `resultsPublishedAt` on the run itself, which is on the run LIST as well, because *"which of last month's events still have no published results"* is a question about a list. A run whose participants are collected and unranked is a real and visible state rather than an error.
**Phase 8 added one route and one body field, and neither moved anything.** `cleanup` is the manual
retry; `cancel`'s `{ cleanup }` is the choice about what a cancelled run leaves behind. A run's
detail gained a fifth and sixth derived field: **`resources`**, the whole ledger with the `@step`
placeholders filtered out — they are core's own bookkeeping and a list of them would read as
resources nobody can name — and **`unresolvedResources`**, counted over the whole ledger *including*
the placeholders, which is why it can exceed the length of the list. That is deliberate: a run whose
answer was lost has something unresolved and nothing to show, and the console says exactly that.
**Phase 6 added the three before it.** `GET/PUT
/admin/events/actions` is the switchboard, `admin` in **both** directions — §K puts it in the same
row as the world-changing actions it governs, and knowing exactly what a deployment permits is not a
staff-wide read. The PUT takes **one action per request** rather than the whole board: the board is
rendered from the registry, so a whole-board write would have to decide what an action *missing* from
the body means, and on a screen listing what registered on this boot that is "a module booted between
the read and the write" — answering it by writing a default over an admin's stored choice is quiet
data loss.
`POST /admin/events/:id/verify` is the dry run, at `admin, editor`. **What it verifies follows the
definition's state**, and the answer says which: a `ready` definition is checked against its
PUBLISHED version, because that is the only thing that ever actually runs and it is that pass §K's
gate is about; a draft is checked against the working spec the author is still holding. Only a pass
against a version is recorded — a version is immutable, so a pass on one stays true, while a pass on
a draft would be a claim about a spec that changes under the author's hands. **Findings come back
with a 200**: the request succeeded, the plan has problems, and answering 4xx would make "this event
asks for 45 and you allow 30" indistinguishable from "you sent a bad event id".
Nothing is absent from this list any more: `advance` left it in Phase 5, `cleanup` and cancel's
`cleanup` flag in Phase 8.
**Phase 13 added the one route this table did not anticipate**, and it is worth saying why it is a
route rather than arithmetic in the browser. A module's `cost()` is a function of params evaluated on
the server and nowhere else (§F), so a meter has nothing to add up until something asks. The dry run
is the wrong thing to ask 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. Neither belongs behind a keystroke. `POST /admin/events/price`
does neither, which is the whole of its design.
**Phase 14a added four routes and no admin route at all**, which is the shape a public phase has: the
three `/public/events` reads and `GET /player/events/history`. Nothing moved. The one change to an
existing answer is a field — `listed` on a definition, in both directions on the admin routes, because
the switch that decides whether an event is announced is edited where the event is authored.
**They are a projection rather than a filtered admin shape, and that is the security property.** Every
other reader of these tables is staff; these are read by nobody at all, so nothing is spread and a
field is published because a line put it there. Building the public answer by DELETING fields from an
operator's is the direction that fails silently — a column added to `event_runs` next year would ride
out through a spread the day it was added.
> **The whole-plan cost check is the one thing no other path makes.** Every per-step check the dry run
> performs is also made at save or at dispatch; the TOTAL is not. Three steps each spawning 15 under a
> cap of 30 pass every individual check and breach the cap on the third — at two in the morning,
> unattended, with the world half-changed. Adding the costs up across a whole version is what only a
> look at the plan as a whole can do, and it is why a dry run is worth more than the sum of its step
> checks.
A run's detail gained a fourth derived field: **`budget`**, one row per dimension with what the run
has spent, the cap it was given, and *which action's switch set that cap* — so a number on the meter
traces back to something an operator can change rather than a figure they have to go looking for.
A third derived field joined `waitingSteps` and `parked` there: a run's detail carries **`gates`**,
one entry per phase that authored an advance condition, already rendered. Every gate the run has
opened is returned rather than only the current phase's — *"how long did phase 2 wait, and what
released it"* is the same question as the live one, asked the morning after.
Two response fields arrived with the console and are worth naming because both are **derived, not
columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST
can say so — a cue nobody notices is a run that never advances while looking perfectly healthy from
the outside. A step carries `parked`, a boolean, because the console has to tell a cue waiting on a
person from a step some process is mid-dispatch on, and it must not do so by being shown
`claimed_by` and `claim_expires_at`: those are the runner's business, and a UI that reasoned about
leases would be a second opinion about who owns a row.
A module registers actions server-side and adds **no routes** for them beyond its option endpoints,
which is what keeps the browser from being able to name a transport.
---
## Observability
An administrator must be able to answer *"why didn't phase 3 start?"* without reading server logs —
and there is a piece of reuse here better than it first looks. The condition grammar already carries
**human labels**, written for the rule editor's dropdowns: `eq` is *"is"*, `gte` is *"is at least"*,
`present` is *"is present"*. The same labels render the diagnosis. **Built in Phase 5**, on the
server, from those labels — the caps line arrives with the budgets in Phase 6.
```
Phase 3 — "The Boss" has not started.
waiting on: uo.champ.boss_up where region is "Yew"
seen so far: 0 of 1
since: 20:31:04 (28 min)
caps: uo.creatures 24/30 · uo.bosses 0/4
last related event: uo.champ.started at 20:31:04
```
| Recorded | Where |
| --- | --- |
| Definition edits, publishes and manual transitions, with actor | `event_run_log` + `activity_log` |
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
| Cap draws and refusals, per dimension | `event_run_budget` + `event_run_log`, kinds `run.budget` (the caps a run was seeded with, and which switch set each) and `step.refused` (the dimension, what was asked, what was already spent). **`step.refused` is its own kind rather than a `step.status`**, so a run that stopped because the deployment does not permit something does not read as a run that broke. Phase 6 |
| A version passing its dry run, on the runs already pinned to it | `event_run_log`, kind `version.verified` — the line that explains why a held occurrence is suddenly going to start |
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources`, plus six `event_run_log` kinds: `resource.recorded`, `resource.orphaned`, `cleanup.reverted`, `cleanup.failed`, `cleanup.swept`, `cleanup.retry`. `resource.recorded` is written at the ANSWER rather than at the placeholder, because a placeholder is a promise and the operator's question is about the world |
One caution carried over from the engagement retention work: the run log is high-cardinality and grows per event, so it needs a
retention sweep from the start — `engagementRetentionPrune` is the pattern, and the rule it learned is
that only *terminal* rows are eligible.
---
## M — Phased implementation plan
> **The plan of record is [`EVENTS_PLAN.md`](EVENTS_PLAN.md)**, which decides order: what lands in
> which pull request, what each ships on its own merit, how each is proved, and the traps in each.
> It supersedes the summary below, which is kept because it is the shape the design argued for and a
> reader of this document should not have to leave it to see the arc. Where the two disagree, the
> plan is the one that has been checked against the repos.
Seventeen PR-sized slices (P0–P16) on an `edge` branch with one cutover, matching the discipline every
previous workstream used. Phases 1–10 and 13–16 ship a complete, useful system that schedules,
announces, orchestrates and records — **without creating or changing anything in the game world** —
and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **§N1 was answered on
2026-09-01 and the gate is lifted.**
| Phase | | Repos |
| --- | --- | --- |
| **P0** ✓ | Design of record; §N answered 2026-09-01; `ADMIN_CONTROLS.md` §8 amended | `docs` |
| **P1** ✓ | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` |
| **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` |
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
| **P5** ✓ | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
| **P6** ✓ | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
| **P7** ✓ | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
| **P8** ✓ | The resource ledger, leases and generated cleanup | `website` `docs` |
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
| **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |
| **P11a** | *N1 answered.* Protocol 6: the idempotency key, and `champ.boss.killed` | `servuo-plugins` `link` `module-uo` `docs` |
| **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` |
| **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` |
| **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` |
| **P14a** ✓ | Public calendar, event pages, arcs, player history, the `events` capability | `website` `docs` |
| **P14b** | The app's events screens — and the module-path fix, which is its prerequisite | `android-app` `docs` |
| **P15** | Integration Kit chapter 5 — cannot merge before the cutover exists | `integration-kit` |
| **P16** | Acceptance walk against released artefacts, then `edge` → `main` | all |
**Three defensible stopping points**, in case scope has to shrink. **After P5:** a scheduled,
multi-phase, condition-advanced engine that announces through core's own actions and cues a human for
anything in-world — roughly a third of the work, and a real product. **After P10** — the recommended
minimum viable scope — adds the module seam, the ledger, the first UO actions and every notification
channel, and needs **no answer to N1 at all**. **After P14:** everything, including the world verbs.
P15 and P16 are not optional if anything ships to `main`; they are the cost of the `edge` discipline.
---
## N — Decisions
**All eleven were settled by the org lead on 2026-09-01**, before any code, which closes the second
half of [`EVENTS_PLAN.md`](EVENTS_PLAN.md) Phase 0 and lifts the ⚠ gate from P11 and P12. Each entry
below keeps the recommendation it was decided against, so the reasoning survives alongside the answer.
**Nine went the way the recommendation argued; two did not** — N2, where starting a run became
`admin`-only, and N4, where the deployment is declared single-instance for good.
Two answers have consequences outside this document, and both are written where they will be found
rather than only here:
- **N1** is an amendment to [`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) §8, recorded there
as a dated amendment with its reasoning. §8's locked scope is reopened in two places, not one — see
the decision below.
- **N3** closes [`ENGAGEMENT.md`](ENGAGEMENT.md) §7.1 Q6, whose "no evaluator yet" row is updated in
the same pull request.
| | Question | Decision |
|---|---|---|
| N1 | Scope of the §8 reopening | **As recommended, including item grants** — §8 is amended in two places |
| N2 | Role split across the four surfaces | **Starting and publishing are `admin` only**; a moderator gets live control of a run already in flight and nothing more |
| N3 | Runner as the `kind: 'scheduled'` evaluator | **Yes** — one scheduler, not two |
| N4 | More than one app instance? | **No, and not later** — single-instance is a recorded assumption; the claim paths stay, the two-instance test is not built |
| N5 | Live console: poll, or core SSE? | **Poll** in v1 |
| N6 | Module-declared risk classes? | **No** — four closed values, core-owned |
| N7 | Plugin-side runtime and phase persistence | **The shard stays stateless about events**; reconciliation-on-reconnect reunites objects with phases |
| N8 | One event invoking another? | **Not as a feature** — composition covers it |
| N9 | Does core know a reward is a reward? | **No** — a reward is an ordinary action with its own cap dimension |
| N10 | Who verifies the lease allowlist? | **The plugin, at boot** — set, read back, restore; a key that does not take drops itself from the catalog |
| N11 | Caps per run, or also per period? | **Per run only** |
**N1 · Confirm the scope of the §8 reopening.**
The direction is settled; what needs writing down is the boundary. Recommendation: permit two things
and nothing else — **event-owned creation** (creatures, NPCs, gates, decoration, item grants), all
cap-bounded and deleted at teardown; and **leased mutation** of a plugin-declared allowlist,
baseline-recorded, drift-checked and deadline-enforced by the game itself. Keep permanently excluded:
loot-table changes, and arbitrary `[set` / `[get` / `[add` — which is precisely "change something with
no baseline recorded". This should be an amendment recorded in `ADMIN_CONTROLS.md`, not a silent
reversal.
> **Decided 2026-09-01 — as recommended, item grants included.**
>
> **`ADMIN_CONTROLS.md` §8 is reopened in two places, and the amendment says both out loud.** §8's
> locked scope cut the Tier-H verbs "entirely — not now, not later", and that list names **item/gold
> grants** as well as world creation. Permitting an event to grant an item is therefore not a widening
> of §8's creation cut; it is a second, independent reversal of a decision that was taken deliberately,
> and an amendment that mentioned only the first would be the silent reversal §8 deserves not to get.
>
> **What makes the grant a different proposition from the one §8 refused** is the four properties it
> did not have then, all of which this document requires before P12 ships: it is *declared* by a module
> rather than typed as a command, *cap-bounded* by `uo.rewards` in a conditional `UPDATE` a stolen
> session cannot talk its way past, *ledgered* in `event_run_resources` with the run, step and actor
> that produced it, and *idempotent* on the wire so a lost acknowledgement cannot double it. §8's
> objection was to an unrecorded change with no baseline; a recorded, bounded, attributable one is a
> different argument. It remains `reversible: 'none'` — a granted item is not clawed back — which is
> exactly why the cap and the ledger carry the whole weight here and are non-negotiable.
>
> **Still permanently excluded, and this decision does not touch them:** loot-table changes, and
> arbitrary `[set` / `[get` / `[add`. Those are the "change something with no baseline recorded" case
> and no cap makes them safe.
**N2 · Confirm the role split across the four event surfaces.**
Settled in principle — gate on the existing roles, add no permission system — so what is left is the
mapping, and it is a judgement call rather than a design one. Recommendation in [§K](#k--security-model):
viewing is the staff tier gate; authoring is `admin` + `editor`; publishing, starting and live control
are `admin` + `moderator`; anything above `notify` and the action switchboard are `admin` only. The one
worth a second look is whether a moderator should be able to start a run at all.
> **Decided 2026-09-01 — no, and the recommendation was changed because of it.** Publishing a version
> and starting a run are **`admin` only**. A moderator keeps **live control of a run already in
> flight** — cancel and abort — and nothing more.
>
> **The asymmetry is the point.** Starting is the act that commits the deployment to everything the
> definition contains, unattended, up to every cap it declares; cancelling is the act that stops it.
> One of those wants the narrowest possible gate and the other wants the widest, because the moderator
> role exists for incident response and the incident here is "the event is doing something wrong at
> 2am". Gating the stop button on the same role as the start button would be the version of this that
> reads consistent and behaves badly.
>
> This is the one place the split departs from the `requireRole('admin','moderator')` /
> `requireRole('admin')` shape module-uo uses, and [§K](#k--security-model)'s table is the normative
> statement of it.
**N3 · Does the event runner also become the `kind: 'scheduled'` trigger evaluator?**
Recommendation: yes. It closes ENGAGEMENT §7.1 Q6, and a second timer doing the same shape of work is
how a codebase acquires two schedulers that disagree.
> **Decided 2026-09-01 — yes, as recommended.** [`ENGAGEMENT.md`](ENGAGEMENT.md) §7.1 Q6's row is
> updated from "no evaluator yet" in the same pull request as this decision. The evaluator itself is
> built by P2, which is the phase that acquires the tick.
**N4 · Is this deployment ever more than one app instance?**
Affects nothing in the design — every claim is already multi-instance safe — but it decides how hard
P2's tests must work, and whether the answer is "safe" or merely "documented".
`docker compose up --scale app=2` is how the engagement workstream's Phase 13 acceptance walk found the seed race.
> **Decided 2026-09-01 — one instance, and not planned to change.** The `app` service runs as a single
> container; there is no load balancer, no rolling deploy with two containers briefly alive together,
> and no intent to add either. **P2 does not build the two-instance test.**
>
> **Every claim in [§E](#concurrency) stays exactly as specified, and this decision is not a licence to
> simplify them.** They are not only there for a second container: the unique index and the CAS are
> equally what protect a tick that runs long while the next one fires, and the lease and its reclaim are
> what recover a step whose process died mid-dispatch — both of which happen in a single-instance
> deployment. They also cost nothing to keep, being one index and one `WHERE` clause.
>
> **What this actually buys is a smaller P2 and one honest sentence:** the multi-instance property is
> *by construction*, not *proved*. If this deployment is ever scaled, or acquires a rolling deploy, the
> event runner is the component most likely to break and it breaks by duplicating world writes rather
> than by erroring — so scaling is the trigger to build the test that was skipped here, and this
> paragraph is where whoever does that will find out why it does not already exist.
**N5 · Does the live run console poll, or does core gain SSE?**
Recommendation: *poll* in v1. Core has no SSE at all and adding one is a larger core change than this
needs. If a live channel is ever built it should be core infrastructure with module-uo's two streams
migrating onto it, not an event-system side effect.
> **Decided 2026-09-01 — poll, as recommended.** The run console polls; core gains no SSE in this
> workstream.
**N6 · May a module declare its own risk classes?**
Recommendation: no. Four closed values, core-owned. A module-defined class is a module choosing which
gate it sits behind. Budget *dimensions* are the opposite case and are module-declared, because core
cannot name what a game counts.
> **Decided 2026-09-01 — no, as recommended.** Four closed, core-owned risk classes. Budget
> dimensions stay module-declared.
**N7 · Where does the plugin-side runtime live, and does a phase persist across a shard restart?**
An asymmetry needing a decision: a spawned creature is an object and persists; a *phase* does not
exist on the shard at all. Recommendation: keep the shard **stateless about events** — it owns objects
and leases, core owns phases — and make reconciliation-on-reconnect the mechanism that reunites them.
A plugin that remembered a phase would be a second event engine.
> **Decided 2026-09-01 — as recommended.** The shard is stateless about events: it owns objects and
> leases, core owns phases, and reconciliation-on-reconnect is what reunites them.
**N8 · Can one event invoke another?**
Recommendation: not as a feature. It already works by composition — a second event's trigger condition
can be `event.run.completed` — and a direct edge would need cycle detection, a depth bound and a story
about cancelling a parent. Series and arcs cover the storyline case without invocation.
> **Decided 2026-09-01 — not as a feature, as recommended.** Composition covers it; there is no
> direct edge, therefore no cycle detection and no depth bound to design.
**N9 · Does core need to know a reward is a reward?**
Recommendation: **no**. A reward is an ordinary action, enabled or not on the switchboard like any
other, with its own cap dimension — a shard that wants none simply never enables it. An action
returning a resource that carries a `memberKey` is enough to answer who received what, and inventing a
category would put core back in the business of knowing what a game can give.
> **Decided 2026-09-01 — no, as recommended.** A reward is an ordinary action with its own cap
> dimension. Core learns no reward category.
**N10 · Who verifies the lease allowlist, and how often?**
A key that is live-read today can become `static readonly` in a later ServUO release, and the failure
is silent — the lease applies and nothing changes. Recommendation: the allowlist ships with the plugin
and each key carries a boot-time self-check (set, read back, restore) that drops a key from the
advertised catalog if it does not take. Better a capability that disappears loudly than one that lies.
> **Decided 2026-09-01 — as recommended.** The allowlist ships with the plugin and each key
> self-checks at boot, dropping itself from the advertised catalog if the write does not take.
**N11 · Are caps per run only, or also per period?**
Recommendation: **per run only**. A cumulative cap that silently exhausts mid-event is a worse failure
than one that refuses at authoring time, and with no delegation there is nobody whose longer-term
allowance needs tracking. Per-period is a later addition if it is ever wanted.
> **Decided 2026-09-01 — per run only, as recommended.** Per-period caps are a later addition if
> they are ever wanted; nothing in the schema forecloses one.
---
## O — Recommendations beyond the brief
**The lease is the most reusable thing in this document, and it outlives the event system too.**
"Hold this value for four hours, then put it back, and put it back even if the website never comes
home" is a primitive a platform for private servers wants regardless of whether events exist — a
weekend rate bonus, a maintenance mode, a temporary cap during an incident. It is also the answer to
the objection behind §8: an unrecorded change is high blast radius, a baseline-recorded, drift-checked,
self-expiring one is a different argument entirely.
**The resource ledger is worth building even if the Event System never ships.**
A durable, queryable record of *what the website changed in the game and whether it can be undone* is
the platform's largest missing safety property. Today the moderation write plane records that an action
happened and nothing about what it produced. It is also what makes the reward audit answerable — who
received what, from which step, in which run.
**Participation attribution was the largest piece of new UO work.** ✅ *Built in Phase 11b.*
With points cut, spawning was never the hard part — attribution was. Results, profile history, the
calendar's "what happened" and any future recognition all rest on trustworthy "who took part", and UO
gives no run-scoped attribution that can be synthesised from the website side. So the shard counts it:
presence in a declared area plus kill credit inside it, keyed by character serial, and **persisted in
the world save** — which made it the Bridge's first persisted state, because a run spans hours and an
in-memory tally would regress every attendee's score after one restart.
**The GM cue step makes the system useful before any protocol change.**
"Post the instruction, wait for a human to confirm, advance" needs no module, no protocol and no world
write — and it lets the website orchestrate, schedule, announce, track and audit while a GM does the
target-driven parts in-client, exactly as §8 argued they should. It is the cheapest thing in this
document and possibly the highest-value.
**Dry run should be a contract obligation, not a UI option.**
Make `verify` a required parameter of `perform` that a module must honour, with a test in the kit. It
is the project's idiom in two other tools, and a module that ignores it will not be caught by review.
**Rehearsal mode is nearly free and disproportionately valuable.**
Running for real with announcements ceilinged to `staff` needs no new mechanism. It is the difference
between finding out phase 3 never advances during a test and finding out during the event.
**Build the calendar as a real data model, because the thing it replaces is not one.**
Series, recurrence, arcs, per-shard local time, categories, scope filters, results. UO's own calendar
has none of it — a monthly fishing contest and a weekly spy mission are re-entered by hand every time,
and a three-part storyline is only connected in prose. This is the least glamorous part of the feature
and probably the part an operator would notice first.
**Let events run with no game at all.**
Nothing in the core engine needs one. A wiki drive, a forum contest, a Discord tournament is a
definition with announce steps, a schedule, participants and results, and it works on bare core with
zero modules. It costs one nullable column, it is a genuinely larger product than the brief describes,
and it is the strongest possible proof that core learned no game vocabulary.
**Do not build a node-graph editor.**
Named explicitly because it is what this feature attracts. The engine has no branching, the condition
grammar is closed at depth 5, and a canvas would advertise power that does not exist. A timeline tells
the truth.
---
*No code was written. Every codebase claim was read from the working trees on 2026-09-01; where a
document and the code disagreed, both are recorded in [§B](#b--existing-gaps). The two UO programs are
summarised from uo.com's Player Event Coordinator page and its Event Moderator directory and event
calendar, read 2026-09-01.*