diff --git a/website/EVENTS.md b/website/EVENTS.md
new file mode 100644
index 0000000..735916e
--- /dev/null
+++ b/website/EVENTS.md
@@ -0,0 +1,1151 @@
+# 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.
+
+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.** | `rpc.rs`: an in-memory pending map, a flat 10-second timeout, three correlation fields, no replay. A command whose ack is lost is indistinguishable from one that never applied. |
+| 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.
+
+**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
+
+Nine core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
+tables carry no module prefix.
+
+| 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`, `concurrency_key`, `grace_seconds`, timezone, 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. |
+| `event_series` | id, name, description, ordering. Definitions optionally belong to one. | **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`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. |
+| `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`, `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 registered action, all disabled above `notify` until an admin says otherwise. Not a grant table — nobody is named, because the role check already answered who. |
+| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap` — so the cap holds under concurrent steps without a transaction. |
+| `event_run_resources` | `run_id`, `step_id`, `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 non-reverted rows | **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. |
+| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `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. |
+| `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. |
+
+### 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 the cap breach, and it is deliberately not `failed` — nothing is wrong with the system. |
+
+### 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.
+
+**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.
+
+### 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. |
+| 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. |
+
+### 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.
+
+```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.
+
+```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 }
+ },
+}])
+```
+
+### What is contract rather than implementation
+
+- **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.
+- **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.
+- **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.
+- **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.
+
+---
+
+## 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 | 🔗 | Inferable from `champ.update` going `bossUp` true → false plus `mob.killed`. Workable and slightly fragile; a dedicated `champ.boss.killed` kind is a small honest addition worth doing alongside anything else on the wire. |
+| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. |
+| Oracle NPC with scripted dialogue | 🔧 📡 | 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. |
+| Temporary gate to a venue | 🔧 📡 | 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. |
+| Temporary decoration lockdown | 🔧 📡 | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. |
+| Named, hued creatures from an allowlist | 🔧 📡 | 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. |
+| "Simple" boss variants | 🔧 📡 | 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. |
+| **Lease a live config value** — rates, toggles, caps | 🔧 📡 | `Server/Config.cs` is a runtime typed key-value store, so this works — **for keys read live**. The plugin ships a verified allowlist, because a lease on one of the `static readonly` keys applies cleanly and does nothing. The single most transferable action in the whole set. |
+| Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. |
+| Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. |
+| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. |
+| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. |
+| Idempotent command application | 🔧 📡 | Nothing on the wire carries an idempotency key and `rpc.rs` has no replay. **Required before any world write ships.** |
+| 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 costs.** Nine new plugin verbs, one protocol bump carrying an
+idempotency key and a lease deadline, and a participation ledger. 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.
+
+---
+
+## 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 `{ ok: true, detail: 'resource no longer exists' }` — "gone, and that is fine" is a successful revert. 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 category, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. |
+| **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. |
+| **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. |
+| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. |
+| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. |
+
+**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.
+
+### Four affordances worth building in from the start
+
+- **Dry run.** 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.
+- **Rehearsal.** 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.
+- **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.
+- **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)).
+
+**Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and
+published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is
+having trouble" is operator information, and the existing shard pages already model exactly this by
+rendering last-known state rather than an error.
+
+---
+
+## 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` public; `run.failed` at `admin`. Events owns **none** of the delivery. | clean fit |
+| **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. | reuse the legs |
+| **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. | small |
+| **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. | 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`, an `events` capability string. **Prerequisite:** the app hardcodes `api/v1/public/shard/stream` — a module path — and reads `/public/modules` nowhere, so it cannot render a site whose module it does not know. | app fix first |
+| **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
+ above `notify` is enabled by default** — the posture a seeded engagement rule already takes,
+ arriving `enabled = 0` because installing a module must never start doing things.
+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.
+
+### 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:
+
+| 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, live controls | `admin`, `moderator` |
+| Any step whose action is above `notify`, and the action switchboard | `admin` only |
+
+> **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.
+
+### 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 above `notify` 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 | A scheduled definition that has never been verified is the case worth refusing to start; verification is cheap and it is the last point a human sees the plan. |
+
+> **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 |
+| --- | --- |
+| **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 it does not exist today.* |
+| **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 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. |
+| **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. |
+| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. |
+| **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 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.
+
+---
+
+## 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. |
+| 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 |
+| `POST /admin/events` | admin, editor | create a draft |
+| `PUT /admin/events/:id` | admin, editor | edit the draft spec |
+| `POST /admin/events/:id/publish` | admin, moderator | 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, moderator | **dry run** — dispatch with `verify: true`, report cost against the caps |
+| `POST /admin/events/:id/runs` | admin, moderator | start now; optional `rehearsal: true`, optional `scope` |
+| `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\|advance\|cancel` | admin, moderator | `cancel` takes `{ cleanup, reason }` |
+| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | `confirm` resolves a GM cue step |
+| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
+| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions |
+| `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param |
+| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps |
+| `GET /public/events` | — | the calendar: upcoming and live, by category, scope and series |
+| `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results |
+| `GET /public/events/series/:slug` | — | the arc |
+| `GET /player/events/history` | auth | this account's participation |
+
+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.
+
+```
+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` + log |
+| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
+| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
+| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` |
+
+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 need no answer to [N1](#n--open-questions). Only P11 and P12 are gated.
+
+| Phase | | Repos |
+| --- | --- | --- |
+| **P0** | Design of record; answer §N; amend `ADMIN_CONTROLS.md` §8 | `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` |
+| **P11** ⚠ | *Needs N1.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` |
+| **P12** ⚠ | *Needs N1.* 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` |
+| **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `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 — Open questions
+
+Each needs an architectural decision before the phase that depends on it. A recommendation is given
+for every one.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+---
+
+## 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 is now the largest piece of new UO work.**
+With points cut, spawning is no longer the hard part — attribution is. 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.
+
+**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.*
diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md
new file mode 100644
index 0000000..0c56a79
--- /dev/null
+++ b/website/EVENTS_PLAN.md
@@ -0,0 +1,529 @@
+# The Event System — phased implementation plan
+
+**Derived from** [`EVENTS.md`](EVENTS.md) revision 5, which is the design of record. This document
+decides *order*: what lands in which pull request, what each one ships on its own merit, and how each
+is proved. It re-specifies nothing — where this and `EVENTS.md` disagree, `EVENTS.md` wins and this
+file is the one with the bug.
+
+**Seventeen phases, P0–P16.** Every phase is independently shippable and leaves the site working.
+**Two are gated** on the open question `EVENTS.md` §N1 (the `ADMIN_CONTROLS.md` §8 amendment) and are
+deliberately late: P11 and P12. Everything else — the engine, the scheduler, the calendar, the
+conditions, the caps, the module contract, the ledger, the first wave of UO actions, the integrations,
+the authoring UI, the public surface — needs no decision beyond P0. Those fourteen phases reach the
+game only to *announce*, over verbs the write plane already carries; nothing in them creates or
+changes a thing in the world.
+
+---
+
+## Before anything: three facts about the ground
+
+**1. `edge` is free, and it is stale.** The engagement workstream is fully cut over as of 2026-09-01 —
+website `main` at `6331b36` (engagement Phase 14, retention), docs `#205` merged, `module-uo`'s `ci/core-ref.json`
+re-pinned to `main`. No pull request is open in any of the six repos bar an automated
+`PROJECT_TREE.md` sync.
+
+But `edge` is **0 commits ahead of `main` and several behind it** in `website`, `docs` and
+`module-uo` — the cutover merged `edge` into `main` and nothing moved `edge` afterwards. So the first
+act of P1 is to **reset each `edge` to its `main`**, not to branch off it as it stands; branching off
+a stale `edge` would silently revert the engagement retention work the moment this workstream cut
+over. Verify per repo before starting:
+
+```bash
+git rev-list --count origin/main..origin/edge # must be 0
+git rev-list --count origin/edge..origin/main # if > 0, edge is stale — reset it
+```
+
+**2. A `MODULE_API_VERSION` bump turns the integration kit red, on purpose.** `ci/core-ref.json` pins
+a `main` sha and `checkCoreApi.js` asserts **equality** with what that sha declares. P7 bumps the
+contract to 1.10.0, so the kit goes red from P7 until the cutover re-pins it. That is the mechanism
+working — it forces someone to re-read the chapters — and it must be stated in P7's PR body so nobody
+"fixes" it. The same is true of `module-uo`'s frozen-manifest check for the length of the `edge`
+window.
+
+**3. `servuo-plugins` has no CI build.** The plugin compiles only inside ServUO, and the dynamic
+rebuild can silently reload a stale `Scripts.dll` — so "it booted clean" is not evidence the new code
+is live. Every plugin-touching phase (P11, P12) verifies against the local tree at
+`C:\Users\colby\Desktop\ServUO` with a build-offline-first step, and confirms new code is live rather
+than trusting a clean boot.
+
+---
+
+## Cross-cutting obligations, every phase
+
+Not repeated per phase below. A PR that skips one of these is not done.
+
+| Obligation | Command / rule |
+| --- | --- |
+| `docs/` updated in the same PR | CLAUDE.md: a code change is not complete until `docs/` reflects it |
+| Server tests | `cd website/server && npm test` |
+| OpenAPI regenerated when a route changed | `npm run swagger` → committed `swagger/swagger-output.json` |
+| Route manifest proves no URL moved | `npm run routes:manifest`, zero-line diff in the PR |
+| No game vocabulary in core | `npm run check:modules` (website root) |
+| module-uo, when touched | `npm run check:imports`, `npm run check:swagger`, `npm run build --prefix client` **before** `npm run check:externals`, then both test suites |
+| Conventional Commits + AI disclosure | `type(scope): summary`; `Co-Authored-By:` trailer; tick the PR-template box |
+| Branch | from an up-to-date `edge`, never a stale one |
+
+---
+
+## Ordering rationale — why this shape
+
+Three choices in the ordering are deliberate and worth stating, because the obvious alternative is
+wrong in each case.
+
+**A demoable surface lands at P3, not at P13.** The rich authoring UI is late (P13) and a *minimal*
+admin surface is early (P3). Splitting the UI in two looks like duplicated work and is not: this
+project reviews in a browser — the org lead's own review stack exists for exactly that — and ten
+backend phases with nothing to click is ten phases without feedback. P3 is a list, a form, a start
+button and a run console. P13 is the timeline editor, the option-source dropdowns and the cap meter,
+built once the schemas they render actually exist.
+
+**The module contract (P7) comes after the engine, not before it.** The temptation is to design the
+seam first. But `registerEventActions` is shaped by what the runner actually needs to hand an action
+and what it does with the answer, and both are unknown until P2 and P6 are built. Core registers its
+own `core.announce` / `core.wait` / `core.cue` through the *same* registry from P1 — exactly as
+`registries.registerCore()` already does for streams and triggers — so the seam is exercised on every
+boot long before a module uses it, and P7 is a generalisation of something working rather than a
+guess.
+
+**Caps (P6) come before the module contract, and before any world write.** A cap is cheap to add to an
+empty system and expensive to retrofit onto a live one, and it is the single control that bounds the
+two failures scheduling makes worse: a typo and a compromised session. P6 is a small phase placed
+early on purpose.
+
+---
+
+## The phases
+
+### Phase 0 — Design of record (`docs`)
+
+> **Half-done.** The pull request that introduced this file and `EVENTS.md` discharges the first
+> half — the design of record exists. What remains is the decisions, and they are the part that
+> gates P11 and P12.
+
+Land `docs/website/EVENTS.md`, and answer §N1–N11. Two of those answers have consequences outside this
+document and should be written where they will be found:
+
+- **N1** is an amendment to [`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) §8, recorded
+ there as an amendment with its date and reasoning — **not** a silent reversal. §8 is cited by name
+ in four places; a decision that contradicts it and leaves it standing is worse than either decision.
+- **N3** (does the event runner become the `kind: 'scheduled'` evaluator) closes
+ `ENGAGEMENT.md` §7.1 Q6, which currently reads "no evaluator yet". If the answer is yes, that row
+ changes in the same PR.
+
+**Ships:** the design of record, and two documents that stop contradicting each other.
+**Verify:** `docs` CI link check; every relative link resolves.
+
+---
+
+### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`)
+
+The six tables that do not depend on the module contract: `event_definitions`, `event_series`,
+`event_versions`, `event_runs`, `event_run_steps`, `event_run_log`. Admin CRUD, publish (which
+snapshots a version), archive. `router/v1/admin/events.router.js` + `events.controller.js`, models as
+`.model.js` / `.db.js` pairs under `model/events/`.
+
+**The registry lands here, with core as its first registrant.** `modules/registries.js` gains
+`registerEventActions` staging and commit, and `registries.registerCore()` registers three core-owned
+actions: `core.announce` (post to an announce leg / broadcast target), `core.wait` (a timed no-op) and
+`core.cue` (post an instruction and wait for a human). None of them execute yet — P2 is what runs a
+step — but the registry, the id grammar, the risk classes and the param validation are all live and
+exercised on every boot.
+
+**Ships:** nothing user-visible; the site is unchanged. A run row can be created and stays
+`scheduled` forever, which is correct for this phase and must be visible as such rather than looking
+broken.
+**Verify:** `npm test`; the registry's collision and validation paths tested the way
+`registries.js`'s existing members are; routes manifest and swagger regenerated.
+
+**Trap:** `register()` must not touch the database (`MODULE_API.md` §2.2) — `routeManifest.js` and
+`swagger.js` both require `app.js` against a dead pool. Core's own action registration is subject to
+the same rule.
+
+---
+
+### Phase 2 — The runner (`website`)
+
+`utils/eventRunner.js`, the eighth poller: same `setInterval` + `unref()` + `stop()` shape as the
+other seven, wired into `server.js`'s start and shutdown beside `engagementWorker`.
+
+Its tick, in order: **materialise** due occurrences (`INSERT IGNORE` against
+`UNIQUE (definition_id, scope, scheduled_for)`); **advance** runs (CAS `scheduled → starting`,
+`running → ending`, materialise the phase's steps); **drain** due steps (CAS `pending → running` with a
+lease, dispatch, classify, record). Executes the three core actions from P1.
+
+Also here: `missed` and the grace window, the lease and its reclaim, `concurrency_key` rendered from
+run params, and `health` as a column separate from `status`.
+
+**Ships:** a manually started event that broadcasts, waits, and completes. Demoable over curl.
+**Verify:** `npm test`, including a **deliberate two-instance test** — the claim paths are the whole
+point of this phase and a single-process test proves nothing about them.
+
+**Two traps, both already paid for once in this codebase.**
+- **A reclaim must not reset `attempts`.** Engagement Phase 14's defect: a sweep that returned every
+ stale row to its start state made `MAX_ATTEMPTS` unreachable, so the row cycled forever, never
+ terminal, therefore never retention-eligible.
+- **The unique index, not the claim, is what prevents a double run.** The claim decides *who*
+ advances an occurrence; the index is what stops two existing.
+
+---
+
+### Phase 3 — The minimal admin surface (`website`)
+
+`client/src/routes/admin/views/EventsAdmin.jsx`, `EventEditor.jsx`, `EventRun.jsx`, plus the nav rows.
+A list with state and next occurrence; a create/edit form; publish; start now; cancel; and a run
+console showing the phase, the step list with status and attempts, and the log.
+
+The spec is edited as **structured fields for the parts that exist** (name, description, schedule,
+phases with their steps) and the step's params as a raw JSON field — a deliberate placeholder that P13
+replaces with the schema-driven editor. Say so in the UI, so it does not read as the finished thing.
+
+`core.cue`'s confirm button lands here, which is what makes the GM cue usable.
+
+**Ships:** an admin can author, schedule, start and watch an event that announces things and cues a
+human. **This is the first phase with a demo**, and it is the one to put in front of the org lead
+before building further.
+**Verify:** the client test suite; a browser walk on the local review stack.
+
+---
+
+### Phase 4 — Schedule, recurrence and the calendar (`website` + `docs`)
+
+The closed recurrence shapes — `once`, `weekly`, `monthly` (nth weekday), `manual` — computed in the
+definition's **IANA timezone** and stored as UTC in `scheduled_for`. `event_series` becomes usable: a
+definition may belong to a series, and the series has an ordering.
+
+An admin calendar view (month + list), filtered by state, scope and series.
+
+**Ships:** recurring and scheduled events, and the calendar that replaces the thing this feature
+exists to replace.
+**Verify:** `npm test` with **DST-crossing cases as explicit fixtures** — a Friday 20:00 event in
+`Europe/Berlin` computed across the March and October transitions, and one in a zone with no DST at
+all.
+
+**Trap:** the temptation is a cron string. There is no cron parser in the server dependency tree, the
+only precedent is in the bot (a different process), and a cron expression is the one field an operator
+cannot proofread. Closed shapes render as a form.
+
+---
+
+### Phase 5 — Conditions and phase advancement (`website` + `docs`)
+
+Phase advance on `{ after: '30m' }` and on `{ on: '', where: , count: n }`,
+reusing `engagement/conditions.js` **unchanged** — its grammar, its type checking against the
+declaration, its depth and list bounds, and its operator labels.
+
+The runner subscribes to the trigger stream it already has; `event_run_log` gains
+`condition.evaluated` rows for both outcomes.
+
+**The diagnosis panel lands here**, and it is the phase's real deliverable: the operator question
+*"why didn't phase 3 start?"* answered in the condition builder's own words — `gte` renders as *"is at
+least"*, `present` as *"is present"* — with the tally, the elapsed time and the last related event.
+
+**Ships:** multi-phase events that advance on what happens in the game rather than only on a clock.
+**Verify:** `npm test`; a rig run where a phase legitimately does not advance, confirming the panel
+explains why without a server log.
+
+**Trap:** a condition is validated at **save** against the trigger's declaration, with the offending
+variable named — not at evaluation. A predicate that silently reads `undefined` is a phase that
+silently never advances, and the day you find out is the night of the event.
+
+---
+
+### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`)
+
+`event_action_settings` (one row per registered action, everything above `notify` disabled by default)
+and `event_run_budget` with the conditional increment:
+
+```sql
+UPDATE event_run_budget SET consumed = consumed + ?
+ WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
+```
+
+A breach is `refused` — the step does not run, does not retry, and is surfaced to the author with the
+dimension and the numbers, because "you asked for 40 and this deployment allows 30" is an authoring
+error, not an outage.
+
+**The whole authorisation decision moves behind one function**, `mayInvoke(user, action, run)`: role,
+enablement, cap, and the shard's own switch. Not for tidiness — it is what keeps an EM-style
+delegation model a later option rather than a redesign.
+
+The role split from `EVENTS.md` §K is applied to the routes here.
+
+**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
+anything.
+**Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends;
+a 403 walk across all four roles on every route.
+
+---
+
+### Phase 7 — The module contract (`website` + `docs` + a throwaway test module)
+
+Generalise P1's registry into the public contract: `registerEventActions` with `cost`, `risk`,
+`reversible`, `budgetMs`, `params` and `perform` / `revert`; `registerEventBudgets`;
+`registerEventLeases`; and param **option sources**. `MODULE_API_VERSION` → **1.10.0**, with §2.4 and
+§1.1 written the way every other member is.
+
+Dispatch through the envelope: a rejected promise, a throw, a timeout, a non-object and a missing
+`ok` are all read as `{ ok: false, retry: true }` — the inverse of `registerTeamProvider`'s default,
+because here the expensive mistake is recording a world change that did not happen.
+
+`verify: true` — dry run — is a required parameter a module must honour, with the test in the kit.
+
+**Prove it with a throwaway module, not with module-uo.** A contract validated only against the module
+it was carved out of has not been validated, and P9 should be the *second* consumer of this seam.
+
+**Ships:** the seam. Core still does everything it did before.
+**Verify:** `npm test`; the throwaway module exercising every failure shape; `check:modules` green.
+
+> **This phase turns the integration kit red** and that is the mechanism, not a bug. `checkCoreApi.js`
+> asserts equality against the pinned `main` sha; it stays red until the cutover re-pins it. Say so in
+> the PR body.
+
+---
+
+### Phase 8 — The resource ledger, leases and cleanup (`website` + `docs`)
+
+`event_run_resources` with `lease_until`, the `drifted` status, and
+`UNIQUE (owner_module, kind, ref)` among non-reverted rows — which is what makes two events unable to
+lease one target, and produces a `refused` step at authoring time rather than a corrupted baseline at
+runtime.
+
+**Record before confirm.** The step writes a `pending` row, dispatches, and promotes it on the answer.
+Recording afterwards makes every object whose acknowledgement was lost invisible to cleanup forever —
+so reverting a resource that does not exist must be a *success*.
+
+Cleanup steps are **generated from the ledger** at teardown, on every terminal path — completion,
+cancellation and abort alike. `cleanup_status` is its own column: a run reaches `completed` with
+`cleanup_status = 'incomplete'` and stays on the admin screen, rather than being held `running`.
+
+Reconcile-on-reconnect: the runner asks each ledgered resource's module what is still in force.
+
+**Ships:** the safety property the whole world-write half depends on. Also useful on its own — the
+platform gains a durable record of what it changed.
+**Verify:** `npm test`; a rig run that kills the process mid-run and confirms cleanup completes on
+restart; a run whose revert fails and stays visible.
+
+---
+
+### Phase 9 — UO wave 1: the actions that need no protocol change (`module-uo` + `docs`)
+
+`module-uo` registers its first event actions over the write plane that already exists:
+`uo.broadcast`, `uo.towncrier.post`, `uo.news.post`. Option sources answered **from the spawn
+atlas** — `uo.options.regions`, `uo.options.landmarks`, `uo.options.creatures` — which cost nothing
+new and work with the shard down.
+
+**Ships:** the first end-to-end event against a real shard: scheduled, announced in-game and on the
+site, cued to a GM, completed, recorded.
+**Verify:** the whole rig — ServUO + sidecar + website — running a real two-phase event.
+
+**Trap worth writing into the action declarations.** These three verbs have *different* idempotency.
+`towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is
+not** — a retry is a second announcement to everyone online — and there is no idempotency key on the
+wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
+declaration says why.
+
+---
+
+### Phase 10 — Integrations (`website` + `docs`)
+
+Core registers its own `event.` triggers — `run.scheduled`, `run.started`, `phase.changed`,
+`run.ending`, `run.completed`, `run.cancelled`, and `run.failed` at `ceiling: 'admin'` — with seeded
+templates through the mechanism `registerEngagementSeeds` already provides. Events owns **none** of the
+delivery.
+
+`event_run_participants`, results publication, and the `core.announce.post` action that links an
+existing post to a run and enqueues it through `announce_jobs` — so the in-game town crier and Discord
+both come free as already-registered legs with retry and classification.
+
+`ENGAGEMENT.md` §8.6's row — *"a scheduled event is starting · ❌ needs a manual/scheduled trigger
+type"* — is resolved in this PR.
+
+**Ships:** every announcement channel the platform has, for every event, per user preference.
+**Verify:** `npm test`; a mail-catcher rig confirming an event announcement reaches email, in-app and
+push; the ceiling on `run.failed` proved to exclude a moderator.
+
+---
+
+### Phase 11 ⚠ — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`)
+
+**Gated on §N1.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked.
+
+- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a
+ repeat with the original result; a lease deadline timer that restores baseline **without being
+ asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind.
+- **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar
+ and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to
+ compose.
+- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served.
+- **`installer`:** joins the phase because of the pairing above.
+- **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`.
+
+**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both
+are safety properties, not features.
+**Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new
+code is live; a deliberate duplicate command; a lease whose website is killed before it expires,
+proving baseline returns anyway.
+
+---
+
+### Phase 12 ⚠ — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`)
+
+**Gated on §N1.** The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and
+either owned or borrowed:
+
+| Verb | Owned or borrowed | Cap dimension |
+| --- | --- | --- |
+| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | `uo.creatures` |
+| "Simple" boss variants, as event-owned creature templates | owned | `uo.bosses` |
+| Oracle NPCs with scripted dialogue | owned | `uo.npcs` |
+| Temporary gates | owned, with a deadline | `uo.gate.minutes` |
+| Temporary decoration lockdown | owned | `uo.decor` |
+| A live config value | **borrowed** — a lease | — |
+| A property on an existing object | **borrowed** — a lease | — |
+| Seasonal-event toggle | borrowed | — |
+| World save | neither — a one-shot | — |
+| Item grant | owned, `reversible: 'none'` | `uo.rewards` |
+
+**The lease allowlist ships with a boot-time self-check.** 258 `Config.Get` call sites in ServUO split
+between live reads and values cached at type initialisation, and a lease on the second kind applies
+cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the
+advertised catalog if it does not take — a capability that disappears loudly beats one that lies.
+
+**Ships:** the invasion.
+**Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown
+back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming `drifted`
+rather than a silent revert of their change.
+
+---
+
+### Phase 13 — The authoring UI proper (`website`)
+
+Replaces P3's placeholders. The phase timeline (**not** a node graph — the condition grammar has no
+branching and a canvas would advertise power the engine does not have); the step editor rendering each
+action's declared params, with option-source dropdowns; the live cap meter; dry run; rehearsal.
+
+**Ships:** an administrator can build the invasion without touching JSON, which is the acceptance
+criterion the whole feature was asked for.
+**Verify:** the client suite; a browser walk authoring a multi-phase event end to end with no raw JSON
+at any point.
+
+---
+
+### Phase 14 — Public, player and mobile (`website` + `android-app` + `docs`)
+
+`GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`,
+`/player/events/history`, and an `events` capability string.
+
+**One prerequisite fix, in this phase and not after it.** The Android app hardcodes
+`api/v1/public/shard/stream` — a *module* path — and reads `/public/modules` nowhere, so it cannot
+render a site whose module it has never heard of. That is a small fix and it blocks the app seeing
+events at all.
+
+**Ships:** the public calendar, event pages with their storyline and results, participation history,
+and the app.
+**Verify:** the client and app suites; an emulator walk.
+
+---
+
+### Phase 15 — Integration Kit chapter 5 (`integration-kit`)
+
+An event-capable module. Teach and link out; re-specify nothing — `EVENTS.md` and `MODULE_API.md` stay
+normative.
+
+The four things a second module's author will get wrong, one paragraph each, all invisible until an
+outage: the envelope's failure default, the idempotency passthrough, recording a resource **before**
+confirming it, and under-declaring `cost`.
+
+**Like Teams Phase 11, this cannot merge until the cutover exists** — the kit is pinned to a `main`
+sha, and the contract it teaches is not on `main` until then.
+
+---
+
+### Phase 16 — Acceptance walk and cutover
+
+**The walk first, against released artefacts, not a working tree.** The whole rig — ServUO, sidecar,
+website, emulator — running a real multi-phase event, including three deliberate failures:
+
+1. a mid-run process restart, proving the run resumes and no step double-executes;
+2. a sidecar kill mid-phase, proving the run degrades rather than failing and world writes park;
+3. a cap breach, proving `refused` and an author who is told why.
+
+Then `edge` → `main`, in the order every previous cutover used: the protocol side first, the module,
+core, docs, then the kit's re-pin and `runicgateway.com`.
+
+**Two documents that are cutover-window work by construction.**
+- **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until
+ the cutover lands. Same 12a/12b split the engagement workstream needed.
+- **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new
+ subsystem is.
+
+---
+
+## What this plan does not do
+
+Stated so the omissions are choices rather than oversights.
+
+- **No node-graph editor** (P13). The engine has no branching.
+- **No delegation, grants or proposal queue.** Permissions gate on the existing admin roles. P6's
+ `mayInvoke` keeps it a cheap later option.
+- **No points.** No core points system exists and no write path to any loyalty board exists.
+- **No event invoking another event.** It already works by composition — a second event's condition can
+ be `event.run.completed` — and a direct edge would need cycle detection and a story about cancelling
+ a parent.
+- **No core SSE.** The run console polls. Adding a live channel to core is a larger change than this
+ feature needs, and if it is ever built it should be core infrastructure with module-uo's two streams
+ migrating onto it.
+- **No mutation of shard-owned content without a baseline.** Loot tables and arbitrary
+ `[set` / `[get` / `[add` stay excluded.
+
+---
+
+## Dependency summary
+
+```mermaid
+flowchart TD
+ P0["P0 · design of record"] --> P1["P1 · schema + registry"]
+ P1 --> P2["P2 · the runner"]
+ P2 --> P3["P3 · minimal admin UI
first demo"]
+ P3 --> P4["P4 · schedule + calendar"]
+ P2 --> P5["P5 · conditions + diagnosis"]
+ P2 --> P6["P6 · enablement + caps"]
+ P6 --> P7["P7 · module contract
MODULE_API 1.10.0"]
+ P7 --> P8["P8 · ledger + leases + cleanup"]
+ P8 --> P9["P9 · UO wave 1
no protocol change"]
+ P5 --> P10["P10 · integrations"]
+ P9 --> P10
+ P0 -.->|"§N1"| P11["P11 ⚠ · protocol bump"]
+ P8 --> P11
+ P11 --> P12["P12 ⚠ · UO world verbs"]
+ P7 --> P13["P13 · authoring UI"]
+ P4 --> P13
+ P10 --> P14["P14 · public + mobile"]
+ P13 --> P16["P16 · walk + cutover"]
+ P12 --> P16
+ P14 --> P16
+ P7 --> P15["P15 · kit chapter 5"]
+ P15 --> P16
+```
+
+**The critical path is P0 → P1 → P2 → P6 → P7 → P8 → P11 → P12 → P16.** Everything else can run
+beside it. P3, P4, P5 and P10 are the phases that could be picked up by a second pair of hands without
+blocking the spine.
+
+---
+
+## If only part of this gets built
+
+Three defensible stopping points, in case scope has to shrink.
+
+**Stop after P5** — a scheduled, multi-phase, condition-advanced event engine that announces through
+core's own actions and cues a human for anything in-world. No module contract, no ledger, no protocol
+change. This is a real product and it is roughly a third of the work.
+
+**Stop after P10** — everything above plus the module seam, the ledger, the first UO actions and every
+notification channel. The website orchestrates and announces; a GM does the target-driven parts
+in-client, exactly as `ADMIN_CONTROLS.md` §8 argued they should. **This is the recommended minimum
+viable scope**, and it requires no answer to §N1 at all.
+
+**Stop after P14** — everything, including the world verbs and the public calendar. P15 and P16 are
+not optional if anything ships to `main`; they are the cost of the `edge` discipline.