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.