Files
docs/website/EVENTS.md
wtclaude 76b2276d36 docs(events): Phase 2 as built - the runner
The docs half of Event System Phase 2. Pairs with `RunicGateway/website`
`feat/events-phase-2`.

EVENTS.md gains what the runner settled: the parked step (`running` with a NULL
lease), the two success-envelope members `await: 'human'` and `holdFor`, the
answer for a run whose concurrency key is held, `n` in §L's `retry(n)`, the rule
that all three `on_failure` dispositions write the step `failed`, and the health
transition on the first retry rather than the eventual failure.

EVENTS_PLAN.md marks Phase 2 complete and records the four org-lead decisions
and the three things the build settled on its own.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 06:32:15 -05:00

105 KiB
Raw Blame History

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 (normative module contract), MODULE_SYSTEM.md, ENGAGEMENT.md and ../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 24 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), 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 17 of §M 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 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:

// 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).
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).
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).
  • 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.

flowchart LR
    subgraph core["CORE — game-agnostic"]
        direction TB
        cal["Calendar &amp; series<br/>schedule · recurrence · arcs"]
        auth["Authoring<br/>definitions · versions · phases · steps"]
        enab["Enablement &amp; caps<br/>per-action switch · per-run caps"]
        run["Runtime<br/>scheduler · steps · claims · retries"]
        ledg["Resource ledger<br/>what this run created or leased"]
        logs["Run log &amp; audit<br/>why didn't phase 3 start?"]
    end

    subgraph mod["INSTALLED GAME MODULE"]
        direction TB
        m["module-uo — declares verbs, their costs<br/>and their budget dimensions; translates each<br/>into a sidecar command; names its own<br/>resources for the ledger<br/><br/><i>module-rust answers the same calls with<br/>Oxide commands and its own verbs</i>"]
    end

    side["Sidecar<br/>durable store · versioned wire · dumb forwarder"]
    game["Game server<br/>dials out · never listens · owns the world"]

    subgraph eng["EXISTING PIPELINE — unchanged"]
        e["event.run.started → rules → segments → cooldowns<br/>→ 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: '<triggerId>', where: <conditions>, 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, series_order, spec (the working copy — see below), 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, slug, description, ordering. Definitions optionally belong to one, at their own series_order within it. The arc. "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it.
event_versions definition_id, version, spec JSON — phases, steps, schedule, conditions, announcements — published_at, published_by. 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.

spec on event_definitions is Phase 1's one addition to this table's column list, and it follows from "editing a draft is free; no version exists yet" below. A draft's working spec has to live somewhere, and it cannot be an event_versions row: that table is immutable and a run pins one, so a mutable unpublished row in it would be precisely what versioning exists to prevent. Publishing copies the column into a version and leaves it standing as the next draft. series_order is the same kind of addition — event_series.ordering places a series among the others, and a definition's place within its arc is the column an editor drags.

Not tables, deliberately

  • Phases — configuration in event_versions.spec, materialised as steps when a run starts. A phase has no identity a query needs; a step does.
  • Actions, budget dimensions and conditions — registry entries declared by a module at register(), like streams and audiences. Never stored; a stored one would outlive the module that can perform it.
  • A reward catalog — not a table and not a registry either. A reward is an ordinary action (§F), 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).
  • A temporary leaderboard entityevent_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. A step waiting on a human is running with a NULL lease (Phase 2, below).

The scheduler

One worker, utils/eventRunner.js, in the website process beside the other seven — not the bot, which cannot load module code. Same setInterval + unref() + stop() shape, wired into server.js. Its tick does three things in order: materialise due occurrences per scope with INSERT IGNORE against the unique key; advance runs by claiming scheduled → starting and evaluating phase conditions; drain due steps, checking caps, dispatching, classifying, recording resources.

As built in Phase 2, the tick has four legs and one of them is smaller than the above implies. Ordered: reclaim (release leases whose holder died), materialise, advance, drain, then a prune on its own six-hourly clock. What "materialise" covers today is only the grace window — the spec validator accepts kind: 'manual' alone until Phase 4, so there is no recurrence to expand and the only occurrences that exist are the ones an admin created. The half that is already real is the half that already matters: a run whose instant passed while the process was down becomes missed rather than starting late and silently. Phase 4 adds the expansion above it.

Three numbers govern a step, and they live in the runner rather than in a column because no authoring surface would ever show them: EVENT_STEP_MAX_ATTEMPTS (3), EVENT_STEP_RETRY_MS (60 000, flat), and EVENT_RUN_LEASE_MS (15 minutes). A step's own lease is not one of them — it is computed from that action's declared budgetMs plus a minute, because a registry that lets an action declare an hour would otherwise have its steps reclaimed and re-dispatched fifty-nine minutes before they answered.

Serial within a phase. The runner works the lowest-seq step of the current phase that is not terminal, and does nothing with the one after it until that one finishes. This is the only reading under which core.wait means anything, and the only one under which a cue can gate what follows it.

Two scheduling decisions the calendar forces.

Schedules are timezone-aware, and the timezone belongs to the event. Every EM listing is in the shard's local zone — KST, JST, PDT, CDT, CEST — never UTC. A recurrence computed in UTC drifts across a DST boundary and puts a Friday-8pm event at 7pm for half the year. Store the IANA zone on the definition, compute occurrences in it, store scheduled_for as UTC.

Recurrence is a small closed shape, not cron. { kind: 'once', at }, { kind: 'weekly', days, time }, { kind: 'monthly', nth, weekday, time }, { kind: 'manual' }. The only cron precedent is in the bot, in another process, with no parser in the server dependency tree — and a cron string is the one field an operator cannot proofread. Monthly-nth is not padding: the fishing contest on Drachenfels is exactly that shape.

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.

What happens to the run that loses. It is held at scheduled, not failed and not queued (org lead, 2026-09-02). Every tick re-examines it; if the holder finishes inside the grace window the run starts, and if it does not the missed sweep makes the run terminal and visible. Failing it immediately would say the system broke when in fact it correctly declined to overlap two events, and queueing it indefinitely would let an event whose announcement said 8pm begin at 11pm — the exact thing missed exists to prevent. The reason is written to last_error and logged as run.blocked only when it changes, because a line per tick for the length of a grace window buries the one line that matters.

This deployment runs one app instance, and every protection above is built anyway (§N4). The "two instances" column names the hardest contender for each row, not the only one: the unique index and the CAS equally protect a tick that runs long while the next one fires, and the lease and its reclaim are what recover a step whose process died mid-dispatch. Both happen with one container. What the single-instance decision changes is the test, not the design — P2 does not build the --scale app=2 rig — so the multi-instance property is true by construction and unproven by experiment. Scaling this deployment, or adopting a rolling deploy that briefly runs two containers, is the trigger to build that rig before anything else.

Idempotency

// 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.

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',          // <moduleId>.-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 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.

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

Two members of the success envelope mean "succeeded, but not finished" (org lead, 2026-09-02). Both are ordinary envelope members rather than special cases keyed on an action id, so the runner never names a verb, and a module's own long-running action reaches them through the same door core's does:

return { ok: true, await: 'human' }   // PARK. The step stays `running` with a NULL lease;
                                      // nothing advances until a human confirms it.
return { ok: true, holdFor: 300 }     // FINISH, and delay what follows by 300s. The pause is
                                      // the NEXT step's `due_at`, owned by core.

await: 'human' is what makes the GM cue work, and the NULL lease is load-bearing: the stale reclaim only ever takes back a lease that is non-NULL and expired, so a cue posted on Friday is still waiting on Monday rather than being re-dispatched every fifteen minutes. holdFor is what makes core.wait a no-op at dispatch — a perform() that slept would hold its claim for the duration, turn a five-minute pause into a five-minute lease, and be re-dispatched by the reclaim, so a long enough wait would never end. It is bounded at seven days.

A holdFor on the last step of a phase holds the next phase, rather than meaning nothing. The later phase's steps do not exist at that moment — they are materialised on entry — so the instant is carried across the boundary and applied to the new phase's first step. Dropping it would make "announce, wait five minutes, then the next phase" start the next phase at once, which is a wait that silently did nothing.

  • Every method answers with an envelope, and no shape a failure can take reads as success. registerTeamProvider's load-bearing rule, inverted: the team provider's default on refusal is "keep what you have" because staleness is cheap; an action's default is "nothing happened, retry", because the expensive mistake is recording a world change that did not occur. A rejected promise, a throw, a timeout, a non-object and a missing ok are all { ok: false, retry: true }.
  • cost is declared by the module and enforced by core, before dispatch. A module says what a verb consumes; core owns the caps and the arithmetic. A module cannot spend a budget it did not declare, and cannot under-report to escape a cap without lying in code the operator installed — which is the same trust category as its schema fragment.
  • risk is declared and core-enforced, over a closed four-value set. The values are notify · inspect · change · irreversible — deliberately not "world-read/world-write", which are game words a chess ladder has no use for.
  • Params are validated at save and at dispatch, against the declared version. A step stores the action_version it was authored against; a bump makes it render a warning in the editor rather than dispatch a mistyped parameter. A param's example is required, on the optional params as well as the required ones — the same rule registerEventTriggers makes of a variable's example and for the same reason. It is the authoring form's placeholder, it is one word at declaration time, and it is unreconstructable afterwards; a blank box is how an unattended world write comes to be scheduled with a typo in it.
  • Resources are named by the module and owned by core. kind and ref are opaque strings core stores verbatim — ctx.teams.activity.push's exact treatment. Core does the remembering; the module does the meaning.
  • verify: true must change nothing and must answer honestly. Dry run is the project's own idiom (installer --verify, deploy.ps1 -Verify) and it is the only way an operator will trust an unattended, scheduled world write.
  • A lease's deadline goes down the wire, and the game side honours it without being asked again. The module passes until through to its sidecar and the plugin restores baseline when it passes. A module that treats until as advisory has produced a lease that outlives an outage, which is the one thing a lease exists to prevent.
  • A module may only advertise a lease it has verified takes effect. 258 Config.Get call sites in ServUO split between live reads and values cached at type initialisation, and a lease on the second kind applies cleanly and does nothing. Apply, observe, restore — per key, as a test.
  • scope is opaque and may be null. Core passes it through and never interprets it. Core has no concept of "the game being up" — only { ok: false, retry: true } — because a module with six sidecars cannot answer that question in the singular.
  • An action whose module is uninstalled goes dormant, never an error. A step naming it fails terminal with the module named and the run degrades — never a silent skip. The authoring side draws the same line one step earlier, in the shape engagement_rules established for a dormant trigger: a step already in a saved spec may keep an unregistered action and a new step may not add one, so an uninstall is never destructive after the fact — and a dormant step blocks the publish, because a version is what a run pins and a run cannot dispatch a verb nobody registers.
  • Actions and budgets are their own id spaces. An action names a verb, a trigger names an event, a budget names a resource dimension.

Rewards are not a contract member. They are an ordinary action.

An earlier draft had a registerEventRewards registry whose entries carried type, cliloc, itemId and hue. That was four Ultima Online nouns inside a core signature, and it was wrong for exactly the reason the rest of this document is careful about. It is deleted.

// 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 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/broadcastBridgeAdmin.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 24. 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).

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 — with one deliberate departure, decided in §N2: starting a run is admin only, while stopping one is not.

Surface Role
The calendar, run history, the run console, the diagnostic log staff — the tier gate, nothing added
Authoring and editing a draft admin, editor
Publishing a version, starting a run admin only
Live control of a run in flight — cancel, abort admin, moderator
Any step whose action is above notify, and the action switchboard admin only

Why start and stop are gated differently. Starting commits the deployment to everything the definition contains, unattended, up to every cap it declares — it wants the narrowest gate there is. Cancelling is incident response, and the incident is "the event is doing something wrong at 2am" — it wants the widest. A split that read consistent, with one role owning both buttons, would behave badly in exactly the case the moderator role exists for.

Keep the check in one function. The whole authorisation decision — role, enablement, cap, shard switch — should live behind a single mayInvoke(user, action, run) rather than being spread across route middleware. Not for tidiness: it is what makes an EM-style delegation model a later option rather than a redesign. If a deployment ever wants named coordinators with their own budgets, that is one function learning to consult a second table, and nothing else in this document changes.

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
A step retries at all The run goes degraded on the FIRST retry, not on the eventual failure — an event whose announcements are landing on the second attempt is having trouble now, and now is when an operator wants to know. health is not status: the run is still genuinely running (§E).
Core restarts mid-run Nothing is held in memory. The next tick finds steps in running with expired leases, reclaims them without resetting attempts, and continues. A step whose ack was lost is re-dispatched with the same idempotency key.
Core is down when a run should start Within grace_seconds it starts late and the log says so. Past it the run is missed — a terminal state a human can see. An event that begins three hours after its announcement is worse than one that visibly did not.
Game server restarts mid-run server.hello arrives with a changed bootId, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes degraded, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to reconcile; a resource the game no longer has becomes orphaned, never silently reverted.
The sidecar is unreachable Every client call returns { ok: false } rather than throwing — uoLinkClient never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state.
The module is disabled or fails to boot installed_modules records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails terminal with the module named, and the run degrades rather than claiming success. Never a silent skip.
An action succeeds but the ack is lost The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. This is the only mechanism that makes it safe, and 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. n is EVENT_STEP_MAX_ATTEMPTS, 3 by default. All three dispositions write the STEP failed: on_failure says what happens to the run, and a step attempted three times that never worked is failed under every one of them. skipped is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable.
A run is cancelled Pending steps cancelled; a running one is left to finish or time out (nothing can recall a sent command); 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. The spec is re-validated against the registries as they stand at that moment, not trusted from the save that wrote it: a module uninstalled in between must block the publish rather than produce a run that fails at dispatch with the world half-changed.
Editing a ready definition with no live run Creates the next version on publish. Future runs use it.
Editing while a run is live Creates the next version. The live run keeps the version it pinned and is unaffected. The editor says so.
Changing what a running event does Not an edit. The live controls are pause, resume, skip, force-advance and cancel — each logged, each attributable, none mutating a version. Anything more expressive is a cancel and a new run, because a half-executed spec edited mid-flight is neither reproducible nor auditable.

API surface

Following existing conventions: /api/v1, tier gates at the tier index, one capability router per file, #swagger.* annotations beside each route, and a zero-line routes.manifest.json diff proving no URL moved.

Route Gate
GET /admin/events staff definitions, state, next occurrence, health
GET /admin/events/:id staff one definition, working spec included — what the editor reads
POST /admin/events admin, editor create a draft
PUT /admin/events/:id admin, editor edit the draft spec
POST /admin/events/:id/publish admin snapshot a version and go ready
DELETE /admin/events/:id admin archive — never a hard delete while runs reference it
GET /admin/events/:id/versions staff version history
POST /admin/events/:id/verify admin, editor dry run — dispatch with verify: true, report cost against the caps
POST /admin/events/:id/runs admin 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 live control of a run in flight; 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 /admin/events/series staff the arcs a definition may belong to
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

Publish and start are admin; cancel is not — that asymmetry is deliberate and decided in §N2. verify sits with admin, editor rather than with start: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it against the caps before asking an admin to publish it.

Two rows above were added by Phase 1 rather than decided in §N, and both are derived from what the surface needs rather than from a new policy. GET /admin/events/:id exists because the list route serves a summary and the editor needs the whole authored tree; GET /admin/events/series exists because a definition carries series_id and a form cannot offer a value it cannot enumerate. Both are staff reads of data the list route already exposes, so neither widens the surface's reach.

What Phase 1 built, and what it deliberately did not. Definitions CRUD, publish, archive, the version history, the action catalog, the series read and the run reads are live. Every route that acts on a run in flight — pause, resume, advance, cancel, step skip/retry/confirm, cleanup — is absent rather than stubbed, because nothing is in flight until the runner exists: a control that answers 200 and does nothing is worse than one that is not there. verify and GET/PUT /admin/events/actions are absent for the same kind of reason — there are no caps to price against and no switchboard to serve until the phase that builds them.

Phase 2 added no routes at all. It is the runner, and a runner has no surface: a published definition started through POST /admin/events/:id/runs now actually runs, and the run reads that already existed render it moving. The controls above are still absent, and they are still Phase 3's — the shipped demo of Phase 2 is a run that announces, waits and completes without anyone touching it, which is exactly the thing that needs no control. core.cue's confirm is the first of them that has something to act on, and it arrives with the console that shows the cue.

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, 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 (P0P16) on an edge branch with one cutover, matching the discipline every previous workstream used. Phases 110 and 1316 ship a complete, useful system that schedules, announces, orchestrates and records — without creating or changing anything in the game world — and needed no answer to N1. P11 and P12 were the gated pair; §N1 was answered on 2026-09-01 and the gate is lifted.

Phase Repos
P0 Design of record; §N answered 2026-09-01; ADMIN_CONTROLS.md §8 amended docs
P1 Schema, CRUD, and the action registry with core as its first registrant website docs
P2 The runner — materialise, claim, advance, drain; leases, missed, concurrency website
P3 The minimal admin surface — first demo website
P4 Schedule, recurrence, timezones, series and the calendar website docs
P5 Conditions, phase advancement, and the "why didn't phase 3 start?" panel website docs
P6 Enablement, per-run caps, and the single mayInvoke decision point website docs
P7 The module contract — MODULE_API 1.10.0, proved with a throwaway module website docs
P8 The resource ledger, leases and generated cleanup website docs
P9 UO wave 1 — the actions that need no protocol change module-uo docs
P10 Integrations — the event. triggers, participants, results, announce legs website docs
P11 N1 answered. Protocol: idempotency key, lease deadline, participation ledger servuo-plugins link module-uo installer docs
P12 N1 answered. UO wave 2 — the world verbs, owned or borrowed servuo-plugins link module-uo docs
P13 The authoring UI proper — timeline, schema-driven steps, cap meter website
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 edgemain all

Three defensible stopping points, in case scope has to shrink. After P5: a scheduled, multi-phase, condition-advanced engine that announces through core's own actions and cues a human for anything in-world — roughly a third of the work, and a real product. After P10 — the recommended minimum viable scope — adds the module seam, the ledger, the first UO actions and every notification channel, and needs no answer to N1 at all. After P14: everything, including the world verbs. P15 and P16 are not optional if anything ships to main; they are the cost of the edge discipline.


N — Decisions

All eleven were settled by the org lead on 2026-09-01, before any code, which closes the second half of EVENTS_PLAN.md Phase 0 and lifts the ⚠ gate from P11 and P12. Each entry below keeps the recommendation it was decided against, so the reasoning survives alongside the answer. Nine went the way the recommendation argued; two did not — N2, where starting a run became admin-only, and N4, where the deployment is declared single-instance for good.

Two answers have consequences outside this document, and both are written where they will be found rather than only here:

  • N1 is an amendment to ../link/ADMIN_CONTROLS.md §8, recorded there as a dated amendment with its reasoning. §8's locked scope is reopened in two places, not one — see the decision below.
  • N3 closes ENGAGEMENT.md §7.1 Q6, whose "no evaluator yet" row is updated in the same pull request.
Question Decision
N1 Scope of the §8 reopening As recommended, including item grants — §8 is amended in two places
N2 Role split across the four surfaces Starting and publishing are admin only; a moderator gets live control of a run already in flight and nothing more
N3 Runner as the kind: 'scheduled' evaluator Yes — one scheduler, not two
N4 More than one app instance? No, and not later — single-instance is a recorded assumption; the claim paths stay, the two-instance test is not built
N5 Live console: poll, or core SSE? Poll in v1
N6 Module-declared risk classes? No — four closed values, core-owned
N7 Plugin-side runtime and phase persistence The shard stays stateless about events; reconciliation-on-reconnect reunites objects with phases
N8 One event invoking another? Not as a feature — composition covers it
N9 Does core know a reward is a reward? No — a reward is an ordinary action with its own cap dimension
N10 Who verifies the lease allowlist? The plugin, at boot — set, read back, restore; a key that does not take drops itself from the catalog
N11 Caps per run, or also per period? Per run only

N1 · Confirm the scope of the §8 reopening. The direction is settled; what needs writing down is the boundary. Recommendation: permit two things and nothing else — event-owned creation (creatures, NPCs, gates, decoration, item grants), all cap-bounded and deleted at teardown; and leased mutation of a plugin-declared allowlist, baseline-recorded, drift-checked and deadline-enforced by the game itself. Keep permanently excluded: loot-table changes, and arbitrary [set / [get / [add — which is precisely "change something with no baseline recorded". This should be an amendment recorded in ADMIN_CONTROLS.md, not a silent reversal.

Decided 2026-09-01 — as recommended, item grants included.

ADMIN_CONTROLS.md §8 is reopened in two places, and the amendment says both out loud. §8's locked scope cut the Tier-H verbs "entirely — not now, not later", and that list names item/gold grants as well as world creation. Permitting an event to grant an item is therefore not a widening of §8's creation cut; it is a second, independent reversal of a decision that was taken deliberately, and an amendment that mentioned only the first would be the silent reversal §8 deserves not to get.

What makes the grant a different proposition from the one §8 refused is the four properties it did not have then, all of which this document requires before P12 ships: it is declared by a module rather than typed as a command, cap-bounded by uo.rewards in a conditional UPDATE a stolen session cannot talk its way past, ledgered in event_run_resources with the run, step and actor that produced it, and idempotent on the wire so a lost acknowledgement cannot double it. §8's objection was to an unrecorded change with no baseline; a recorded, bounded, attributable one is a different argument. It remains reversible: 'none' — a granted item is not clawed back — which is exactly why the cap and the ledger carry the whole weight here and are non-negotiable.

Still permanently excluded, and this decision does not touch them: loot-table changes, and arbitrary [set / [get / [add. Those are the "change something with no baseline recorded" case and no cap makes them safe.

N2 · Confirm the role split across the four event surfaces. Settled in principle — gate on the existing roles, add no permission system — so what is left is the mapping, and it is a judgement call rather than a design one. Recommendation in §K: viewing is the staff tier gate; authoring is admin + editor; publishing, starting and live control are admin + moderator; anything above notify and the action switchboard are admin only. The one worth a second look is whether a moderator should be able to start a run at all.

Decided 2026-09-01 — no, and the recommendation was changed because of it. Publishing a version and starting a run are admin only. A moderator keeps live control of a run already in flight — cancel and abort — and nothing more.

The asymmetry is the point. Starting is the act that commits the deployment to everything the definition contains, unattended, up to every cap it declares; cancelling is the act that stops it. One of those wants the narrowest possible gate and the other wants the widest, because the moderator role exists for incident response and the incident here is "the event is doing something wrong at 2am". Gating the stop button on the same role as the start button would be the version of this that reads consistent and behaves badly.

This is the one place the split departs from the requireRole('admin','moderator') / requireRole('admin') shape module-uo uses, and §K's table is the normative statement of it.

N3 · Does the event runner also become the kind: 'scheduled' trigger evaluator? Recommendation: yes. It closes ENGAGEMENT §7.1 Q6, and a second timer doing the same shape of work is how a codebase acquires two schedulers that disagree.

Decided 2026-09-01 — yes, as recommended. ENGAGEMENT.md §7.1 Q6's row is updated from "no evaluator yet" in the same pull request as this decision. The evaluator itself is built by P2, which is the phase that acquires the tick.

N4 · Is this deployment ever more than one app instance? Affects nothing in the design — every claim is already multi-instance safe — but it decides how hard P2's tests must work, and whether the answer is "safe" or merely "documented". docker compose up --scale app=2 is how the engagement workstream's Phase 13 acceptance walk found the seed race.

Decided 2026-09-01 — one instance, and not planned to change. The app service runs as a single container; there is no load balancer, no rolling deploy with two containers briefly alive together, and no intent to add either. P2 does not build the two-instance test.

Every claim in §E stays exactly as specified, and this decision is not a licence to simplify them. They are not only there for a second container: the unique index and the CAS are equally what protect a tick that runs long while the next one fires, and the lease and its reclaim are what recover a step whose process died mid-dispatch — both of which happen in a single-instance deployment. They also cost nothing to keep, being one index and one WHERE clause.

What this actually buys is a smaller P2 and one honest sentence: the multi-instance property is by construction, not proved. If this deployment is ever scaled, or acquires a rolling deploy, the event runner is the component most likely to break and it breaks by duplicating world writes rather than by erroring — so scaling is the trigger to build the test that was skipped here, and this paragraph is where whoever does that will find out why it does not already exist.

N5 · Does the live run console poll, or does core gain SSE? Recommendation: poll in v1. Core has no SSE at all and adding one is a larger core change than this needs. If a live channel is ever built it should be core infrastructure with module-uo's two streams migrating onto it, not an event-system side effect.

Decided 2026-09-01 — poll, as recommended. The run console polls; core gains no SSE in this workstream.

N6 · May a module declare its own risk classes? Recommendation: no. Four closed values, core-owned. A module-defined class is a module choosing which gate it sits behind. Budget dimensions are the opposite case and are module-declared, because core cannot name what a game counts.

Decided 2026-09-01 — no, as recommended. Four closed, core-owned risk classes. Budget dimensions stay module-declared.

N7 · Where does the plugin-side runtime live, and does a phase persist across a shard restart? An asymmetry needing a decision: a spawned creature is an object and persists; a phase does not exist on the shard at all. Recommendation: keep the shard stateless about events — it owns objects and leases, core owns phases — and make reconciliation-on-reconnect the mechanism that reunites them. A plugin that remembered a phase would be a second event engine.

Decided 2026-09-01 — as recommended. The shard is stateless about events: it owns objects and leases, core owns phases, and reconciliation-on-reconnect is what reunites them.

N8 · Can one event invoke another? Recommendation: not as a feature. It already works by composition — a second event's trigger condition can be event.run.completed — and a direct edge would need cycle detection, a depth bound and a story about cancelling a parent. Series and arcs cover the storyline case without invocation.

Decided 2026-09-01 — not as a feature, as recommended. Composition covers it; there is no direct edge, therefore no cycle detection and no depth bound to design.

N9 · Does core need to know a reward is a reward? Recommendation: no. A reward is an ordinary action, enabled or not on the switchboard like any other, with its own cap dimension — a shard that wants none simply never enables it. An action returning a resource that carries a memberKey is enough to answer who received what, and inventing a category would put core back in the business of knowing what a game can give.

Decided 2026-09-01 — no, as recommended. A reward is an ordinary action with its own cap dimension. Core learns no reward category.

N10 · Who verifies the lease allowlist, and how often? A key that is live-read today can become static readonly in a later ServUO release, and the failure is silent — the lease applies and nothing changes. Recommendation: the allowlist ships with the plugin and each key carries a boot-time self-check (set, read back, restore) that drops a key from the advertised catalog if it does not take. Better a capability that disappears loudly than one that lies.

Decided 2026-09-01 — as recommended. The allowlist ships with the plugin and each key self-checks at boot, dropping itself from the advertised catalog if the write does not take.

N11 · Are caps per run only, or also per period? Recommendation: per run only. A cumulative cap that silently exhausts mid-event is a worse failure than one that refuses at authoring time, and with no delegation there is nobody whose longer-term allowance needs tracking. Per-period is a later addition if it is ever wanted.

Decided 2026-09-01 — per run only, as recommended. Per-period caps are a later addition if they are ever wanted; nothing in the schema forecloses one.


O — Recommendations beyond the brief

The lease is the most reusable thing in this document, and it outlives the event system too. "Hold this value for four hours, then put it back, and put it back even if the website never comes home" is a primitive a platform for private servers wants regardless of whether events exist — a weekend rate bonus, a maintenance mode, a temporary cap during an incident. It is also the answer to the objection behind §8: an unrecorded change is high blast radius, a baseline-recorded, drift-checked, self-expiring one is a different argument entirely.

The resource ledger is worth building even if the Event System never ships. A durable, queryable record of what the website changed in the game and whether it can be undone is the platform's largest missing safety property. Today the moderation write plane records that an action happened and nothing about what it produced. It is also what makes the reward audit answerable — who received what, from which step, in which run.

Participation attribution 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. 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.