EVENTS.md: the materialise leg as two halves and why the horizon is what makes the missed sweep meaningful; the recurrence shapes as built, incl. the two DST rules and why nth has no fifth; publishing as the schedule switch and the re-pin; the calendar's run-vs-projection distinction and the reader's-zone/event's-zone split; the four new routes; P1-P4 ticked. EVENTS_PLAN.md: Phase 4 marked complete with the five org-lead decisions, the three the build settled, the test-harness defect the phase introduced and fixed, and the verified numbers. Website: RunicGateway/website#PENDING Co-Authored-By: Claude <noreply@anthropic.com>
1515 lines
117 KiB
Markdown
1515 lines
117 KiB
Markdown
# The Event System — investigation and architecture
|
||
|
||
**Status:** Investigation and architecture proposal, revision 5. **No code written.**
|
||
**Read against:** the working trees on 2026-09-01 · `MODULE_API_VERSION` 1.9.0 · sidecar protocol 5.
|
||
**Companion to** [`MODULE_API.md`](MODULE_API.md) (normative module contract),
|
||
[`MODULE_SYSTEM.md`](MODULE_SYSTEM.md), [`ENGAGEMENT.md`](ENGAGEMENT.md) and
|
||
[`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) (the write plane and its exclusions).
|
||
|
||
A game-agnostic engine for scheduled, bounded, audited changes to a live game world — driven by the
|
||
staff who already run the site, and built so core never learns a game word.
|
||
|
||
> **Revision history.** Rev 1 was the first survey. Rev 2 adopted the UO Event Moderator / Player
|
||
> Event Coordinator programs as a model, cut points, and treated `ADMIN_CONTROLS.md` §8 as superseded.
|
||
> Rev 3 corrected rev 2 on mutation (it is practical, as a *lease*) and brought item rewards back.
|
||
> Rev 4 deleted rev 3's `registerEventRewards` registry, which carried four UO nouns in a core
|
||
> signature — a reward is an ordinary action. **Rev 5 removes the delegation model entirely:** the UO
|
||
> programs stay as inspiration for the capability set and the quotas, but permissions gate on the
|
||
> **existing admin roles** — no new role, no grant table, no proposal queue.
|
||
|
||
---
|
||
|
||
## Verdict
|
||
|
||
**1. This is a strong fit, and the differentiating feature is not the spawning.**
|
||
It is the tooling around it, which does not exist anywhere. Broadsword runs roughly twenty-five
|
||
shards' worth of Event Moderator programming on forum posts and a WordPress calendar plugin — with no
|
||
series field, no recurrence, no participation tracking, no results publication and no audit trail.
|
||
Every one of those is something this platform is already shaped to do well and a game client
|
||
structurally cannot do at all. The site→game direction this needs is modest: gates, props, NPCs,
|
||
named creatures, announcements.
|
||
|
||
**2. The generic half is nearly all already built.**
|
||
The staged-registration registry, the seven-value ceiling lattice, the closed condition grammar over
|
||
module-declared typed variables, the compare-and-set outbox claim, the retry/classify leg dispatcher,
|
||
the schema-driven admin form builder, and the spawn atlas that already knows every region, creature
|
||
and altar on the shard — offline, with the shard down. The genuinely new core pieces are a scheduler,
|
||
a step runner, a per-action cap, a resource ledger, and one new module registration.
|
||
|
||
**3. One decision is superseded rather than overturned, and one of its exclusions survives.**
|
||
`ADMIN_CONTROLS.md` §8 excluded world writes on 2026-07-12, when the bridge was a read plane with
|
||
four moderation verbs attached. That reasoning was correct for that platform and does not bind a
|
||
platform whose stated purpose is two-directional. What survives the reopening is **no arbitrary
|
||
`[set` / `[get` / `[add`** — the one exclusion that cannot be expressed as a bounded, recorded
|
||
action. Every world change is either an **event-owned object** that is deleted at teardown or a
|
||
**leased override** that restores itself; item rewards are one more action an admin switches on, or
|
||
does not.
|
||
|
||
---
|
||
|
||
## The model: what UO already runs
|
||
|
||
Two live programs, read from uo.com. **Inspiration, not mechanism** — they are here for what they
|
||
prove about the capability set, the quotas and the event slate, and explicitly *not* as a delegation
|
||
model to build. Permissions gate on the admin roles this platform already has.
|
||
|
||
The ladder below is context for *why the quotas are where they are*. Runic Gateway implements none of
|
||
it: an event is authored and run by site staff, gated on `requireRole` like every other admin
|
||
capability.
|
||
|
||
| Tier | Who holds it | Powers | Oversight |
|
||
| --- | --- | --- | --- |
|
||
| **Developer Event** | the operators | unbounded | none required |
|
||
| **EM Event** | an appointed Event Moderator, **one per shard** across ~25 shards | standing and broad — storyline arcs, recurring series, custom encounters, **and event items** | program rules; published to the official calendar |
|
||
| **Player Event (PEC)** | any player, approved per event | tightly quota'd — see below | two weeks' notice, an in-game review meeting, per-event approval |
|
||
| **Worldwide Event** | the operators | one definition, every shard at once | — |
|
||
|
||
### The PEC quotas, verbatim from the program page
|
||
|
||
These matter because they are the safety model, and they are numeric rather than role-shaped.
|
||
|
||
- **Monsters** — common creatures (orcs, skeletons, zombies, brigands) with custom names and hues;
|
||
regular mobs may be enhanced into "simple" boss variants. **Capped at 30 total monsters, or small
|
||
groups of 2–4 boss-like mobs.**
|
||
- **NPCs** — up to **5** oracle NPCs, dialogue limited to **5 lines each** (3 if keywords or items
|
||
are involved).
|
||
- **Gates** — temporary, **up to 4 hours** per event, approved case by case.
|
||
- **Decorations** — temporary lockdowns of decorative items, including non-craftable pieces.
|
||
Permanent decoration prohibited.
|
||
- **Never** — *"The PEC program NEVER offers drops of any kind for any reason."* No item placement on
|
||
monsters. No dungeon bosses. No cross-facet gating to restricted areas. **Spawners unavailable.**
|
||
- **The drop prohibition is a tier boundary, not a system-wide policy.** It binds the *player* tier;
|
||
EM events are where UO's own event items come from. So an engine has no business forbidding rewards
|
||
outright — here that is an admin switch on one action ([§K](#k--security-model)), not a rule.
|
||
|
||
### Five findings this produces
|
||
|
||
**1. The tiers tell you what the powers are worth, not who should hold them here.**
|
||
Read as a capability list they are excellent: named creatures, boss variants, oracle NPCs, gates,
|
||
temporary decoration, and — at the upper tier — items. Read as an authorisation model they are a
|
||
program for volunteers UO does not otherwise trust, which is not the situation on a self-hosted shard
|
||
where the person authoring the event is the person who owns it. **Permissions gate on the existing
|
||
admin roles.** No new role, no grant table, no proposal queue.
|
||
|
||
**2. Budgets bound blast radius; roles bound identity. Budgets are the better control.**
|
||
Thirty monsters, four bosses, five NPCs, five lines, four hours. Core holds
|
||
`{ dimension, consumed, cap }` per run; a module declares what a dimension is called and what each
|
||
action costs. Rust's dimensions would be prefabs-placed and zone-minutes, and core never learns
|
||
either word.
|
||
|
||
**3. Quotas survive the simplification, and their reason changes.**
|
||
Without delegation a cap is not a trust boundary — it is a guardrail against the two things that
|
||
actually go wrong on an admin-only system: a typo (30 became 3000) and a compromised session. Both
|
||
are real, both are unbounded without a cap, and neither is addressed by a role check. So caps stay,
|
||
as a **per-action deployment setting an admin edits**, not as a property of a grant.
|
||
|
||
**4. Spawn sets, not spawners.**
|
||
PEC withholds spawners deliberately. A running spawner is unbounded over *time*, which is exactly
|
||
what a quota cannot express. The right primitive is a bounded one-shot spawn of N creatures, each
|
||
serial recorded in the ledger and deleted by serial at cleanup — bounded *and* cleanable.
|
||
|
||
**5. Everything above is a special case of one idea: an event holds things temporarily.**
|
||
A spawned creature is an object the event owns until teardown; a gate is the same with a shorter
|
||
fuse; a changed rate is a *value* the event owns until teardown. Generalised, an event never edits
|
||
the world — it **holds a lease**, and baseline is what is true when no lease is held.
|
||
|
||
### What the real calendar shows, and what it is missing
|
||
|
||
September 2026's EM slate: Fishing Contest (Drachenfels), Royal Spy Mission (Great Lakes, roughly
|
||
weekly), Harvest Party, Governor's Meeting, Council Meeting, Royal Britannian Guard, LUDO World
|
||
Championship Final (Test Center 1), Bloodmother, Mini Event, Shrouded Deliveries, Sunsets and
|
||
Snowcaps.
|
||
|
||
**Gatherings, competitions and civic roleplay outnumber monster encounters substantially.** What most
|
||
of that slate needs is a venue, a gate, a few props and NPCs, an announcement, participation tracking
|
||
and a published result — not a spawn engine. That is the single most important input to scoping this
|
||
feature, and it is why phases 1–7 of [§M](#m--phased-implementation-plan) deliver most of the value
|
||
without touching a world write.
|
||
|
||
Each listing carries a title, shard, date, start time, a **local timezone** (KST, JST, PDT, CDT,
|
||
CEST — per shard, never UTC), an in-game meeting location, storyline prose and organiser attribution.
|
||
Three things it does *not* carry are the gaps worth building into:
|
||
|
||
- **No series or recurrence field.** A monthly fishing contest and a weekly Royal Spy mission are
|
||
each hand-entered as standalone occurrences.
|
||
- **No arc.** "Royal Spy Mission" → "Royal Spy | Risky Partner" → "Message From the Void" is
|
||
continuity that exists only in prose.
|
||
- **No results, no participation, no audit.** What happened at an event is not data anywhere.
|
||
|
||
---
|
||
|
||
## Leases: the primitive underneath everything
|
||
|
||
A correction to an earlier revision, which ruled out mutating existing state as impractical. It is
|
||
practical, the framing that makes it so is not "change and change back", and it generalises across
|
||
games better than spawning does.
|
||
|
||
> Modify an existing shard spawner or creature — *not practical.* Mutating shard-owned content means
|
||
> the ledger has to hold a *before* image of somebody else's object and restore it correctly after a
|
||
> crash, a save, a staff edit or a restart.
|
||
>
|
||
> — revision 2, §G
|
||
|
||
Three of those four hazards are not hazards. The before-image lives in the **website's** database, so
|
||
it survives a shard restart intact; a world save simply persists whatever is currently true and
|
||
restoration writes the baseline back over it; a deleted target makes restore a successful no-op. Only
|
||
one is real — **somebody else changing the same property while the event holds it** — and that is an
|
||
ordinary lost-update problem this codebase already knows how to solve.
|
||
|
||
### An event does not edit the world. It holds a lease.
|
||
|
||
Baseline is what is true when no lease is held. That reframing is not cosmetic — four properties fall
|
||
out of it that "edit, then edit back" does not give you:
|
||
|
||
| Property | Why the lease framing produces it |
|
||
| --- | --- |
|
||
| **Temporary by construction** | There is no state in which the world is permanently changed and somebody forgot the undo. The undo is the default; holding is the exception. |
|
||
| **Conflict-detectable** | Two events cannot hold a lease on one target. The ledger answers this with a unique index over `(owner_module, kind, ref)` among non-reverted rows, so the second event's step comes back `refused` at authoring time rather than corrupting a baseline at runtime. |
|
||
| **Reconcilable, not replayable** | On reconnect the module asks "which leases should be in force right now?" and the game side makes reality match. Replaying a log of edits after an outage cannot produce a correct world; declaring the intended state can. |
|
||
| **No new table** | A lease is a ledger row: `kind: 'override'`, `payload: { target, baseline, applied, until }`. It slots into [§D](#d--data-model) unchanged, and `reversible: 'override'` joins the closed set beside `none \| self \| ledger`. |
|
||
|
||
### The two mechanisms that make it safe
|
||
|
||
Both are the whole of the original objection.
|
||
|
||
**1. Restore is compare-and-set, never a blind write.** Before writing the baseline back, verify the
|
||
current value still equals what the event applied. If it does not — a GM changed the same property
|
||
in-client during the event — do **not** restore. Mark the resource `drifted`, leave the world alone,
|
||
and surface it beside the unreverted resources on the run console. Blindly restoring would silently
|
||
revert a staff member's deliberate change, which is the one failure that would make operators
|
||
distrust the whole feature.
|
||
|
||
**2. The expiry lives on the game side, not only in core.** A lease ships with its own deadline and
|
||
the plugin restores baseline when it passes, **whether or not the website is ever heard from again**.
|
||
Core drives normal restoration; the plugin's deadline is the backstop. This inverts the naive design,
|
||
where restoration depends on core dispatching a cleanup step — which fails *open* if core dies
|
||
mid-event. A lease fails *safe*, and that difference is what makes unattended, scheduled world
|
||
changes defensible at all.
|
||
|
||
### What ServUO actually permits — and the trap in it
|
||
|
||
`Server/Config.cs` is a real runtime key-value store with typed `Get` / `Set` / `Save` / `Load`, so
|
||
configuration leases are genuinely feasible. But there are **258 `Config.Get` call sites** in
|
||
`Scripts/` and they split into two patterns that behave completely differently:
|
||
|
||
```csharp
|
||
// Cached at type initialisation. A lease on this key silently does NOTHING.
|
||
private static readonly int MaxAccountsPerIP = Config.Get("Accounts.AccountsPerIp", 1);
|
||
|
||
// Read live at the call site. A lease on this key takes effect immediately.
|
||
if (Config.Get("Staff.Staffbody", true))
|
||
```
|
||
|
||
So the UO lease catalog is a **curated allowlist the plugin ships**, of keys verified to be
|
||
live-read — never "any config key". A module must not advertise a lease it cannot actually deliver,
|
||
because *"the setting applied and nothing happened"* is the worst failure this feature has. That is a
|
||
testable obligation, and the test is mechanical: for each key in the catalog, apply, observe, restore.
|
||
|
||
Beyond configuration the same pattern covers any per-object property whose current value is readable
|
||
before it is written — an existing spawner's `Amount` / `MinDelay` / `MaxDelay`, a named creature's
|
||
stats, a `SeasonalEventSystem` entry's status. Loot stays excluded for the reason it always was: it
|
||
is per-creature-type and shard-wide, so it cannot be scoped to one run at all.
|
||
|
||
---
|
||
|
||
## A — Current architecture: what can be reused
|
||
|
||
Every row below was read in the working tree, not inferred from documentation. Nothing here needs to
|
||
be invented.
|
||
|
||
Four workstreams — modules, Teams, engagement, retention — built exactly the machinery an event
|
||
engine needs, and built it generically each time because a module had to reach it.
|
||
|
||
### Registration and contract
|
||
|
||
| Mechanism | Where | What it gives Events |
|
||
| --- | --- | --- |
|
||
| `registries.js` — stage / validate / commit | `server/src/modules/registries.js`, 1,277 lines | A module registers a batch, shape checked at the call, collisions at commit, and a module that throws leaves *nothing* behind. Any new registry must follow it verbatim. |
|
||
| `registerEventTriggers` | MODULE_API §2.4 · 26 in module-uo | Typed, versioned, example-bearing declarations over a **closed six-type set**. Already the schema language for action parameters — specified, validated, and rendered by a working admin form. |
|
||
| `registerTeamProvider` | MODULE_API §2.4 | The only precedent where core calls a module and waits: an envelope with no shape a failure can read as success, a 10-second budget, explicit fail-closed on the one visibility question. The action contract is this, inverted ([§F](#f--the-module-contract)). |
|
||
| `registerAnnounceLeg` + `announce_job_legs` | `utils/announceWorker.js` | `dispatch → classify → done \| retry \| terminal`, per-leg backoff, one leg's failure never touching another, status rolling up over rows rather than columns. |
|
||
| `ceilings.js` | `modules/ceilings.js` | Seven audience values ordered by containment, with composition that narrows. Gives "who may this event reach" and — for free — rehearsal mode ([§I](#i--admin-ui-authoring-and-the-calendar)). |
|
||
| `installed_modules` state machine | core schema + `modules/lifecycle.js` | A `state` ENUM held *separately* from `failure_stage` / `failure_reason`. That split is the right shape for a run that is running *and* degraded. |
|
||
|
||
### Execution, concurrency and persistence
|
||
|
||
| Mechanism | Where | What it gives Events |
|
||
| --- | --- | --- |
|
||
| Compare-and-set claim | `engagementOutbox.db.claim()` | Multi-instance-safe row claiming, no open transaction, no MariaDB version floor. Chosen by the org lead over `SKIP LOCKED` on 2026-08-29. The step queue should use it unchanged. |
|
||
| `settings.claim()` — `INSERT IGNORE` | `engagement/moduleSeeds.js` | The atomic guard that fixed the double-seed race the **engagement** workstream's Phase 13 hit (52 rules where the module ships 26). Also answers "only one instance may materialise this occurrence". |
|
||
| Poller workers | seven, all `setInterval` + `unref()` + `stop()` | The house pattern. An event runner is the eighth and looks like the other seven. |
|
||
| JSON columns with registry-driven validators | `engagement_rules.conditions`, `pages.blocks`, `engagement_templates.blocks` | The project stores authored trees as JSON validated against a registry rather than normalising them. Phases belong in a version snapshot, not in four more tables. |
|
||
| `engagement/conditions.js` | 251 lines | A **closed predicate grammar** — 12 operators typed against the declaration, `and`/`or`/`not` bounded at depth 5, validated at *save* with the offending variable named. Directly reusable as the phase-advance language, and its operator labels are already written for humans. |
|
||
| `activity_log` + `ctx.activity.log` | core | Admin audit for every definition edit, publish and manual transition, already reachable from a module. |
|
||
|
||
### Surfaces and content
|
||
|
||
| Mechanism | Where | What it gives Events |
|
||
| --- | --- | --- |
|
||
| The engagement pipeline end to end | triggers → rules → segments → cooldowns → outbox → email · in-app · push | Every announcement, reminder and result notice, with Events owning **none** of the delivery. Events emits a trigger; an operator decides who hears about it. |
|
||
| Schema-driven admin forms | `EngagementRules.jsx`, 716 lines | Core rendering a form it does not understand, from a schema a module declared. This is the answer to "no hard-coded UO assumptions in the UI". |
|
||
| The client module registry | `client/src/modules/registry.js` · `window.__rg` · the 9-member UI kit · `declareModuleSlot` | A prebuilt module chunk can register admin routes, nav rows, extension fills and slots on its own pages for core to fill. Both directions of UI composition exist. |
|
||
| **The spawn atlas** | module-uo: `shard_regions`, `shard_spawn_creatures`, `shard_landmarks`, `shard_champion_spawns` | **The authoring vocabulary, free and offline.** Re-derived from the shard's own ServUO tree every boot, fully populated while the shard is down. An event form can offer real region, creature and landmark dropdowns — including the "meeting location" field every EM listing carries — with no shard round-trip. |
|
||
| SSE with per-viewer projection | module-uo `shardBroadcast.js` + `shardVisibility.js` | A live channel that resolves a viewer's rung once at subscribe and field-projects every frame. Note it is **module-owned**, so core cannot assume it (§B4). |
|
||
| The write plane that already exists | sidecar `/admin/broadcast`, `/towncrier`, `/news`, `/pages/…` | Broadcasts, crier lines and full in-game news articles today, behind the shard's own `AdminWriteEnabled` switch and an Owner-protection floor, `actor`-attributed and audited both directions. |
|
||
|
||
> **Unchanged by anything here.** The three-repo data path, the sidecar's durable SQLite store, the
|
||
> shard-dials-out invariant, the bounded drop-oldest queue on the game side, and the module system's
|
||
> zero-internal-imports rule are all load-bearing and stay exactly as they are. This design adds a
|
||
> consumer to them; it renegotiates none of them.
|
||
|
||
---
|
||
|
||
## B — Existing gaps
|
||
|
||
What is genuinely not there, stated as what the codebase says rather than as assumption.
|
||
|
||
| # | Gap | Evidence |
|
||
| --- | --- | --- |
|
||
| 1 | **No scheduler in the website process.** | `node-cron` appears only in `bot/package.json`, and the bot container has no `modules` volume and cannot load a line of module code. The seven core workers are fixed-interval sweeps with no calendar of any kind. |
|
||
| 2 | **`kind: 'scheduled'` triggers are declared with no evaluator.** | ENGAGEMENT §7.1 Q6: *"declare now, build after Phase 9 — no evaluator yet."* `engagementEmit.js:161` actively refuses a direct emit of one. |
|
||
| 3 | **No per-capability switch or cap.** | `users.role` is `ENUM('admin','editor','moderator','player')` and every privileged route is a `requireRole()` call — which is the right gate and is *all* there is. There is no way for an operator to say "this action is available on this deployment, up to this much", which is what a world-changing action needs on top of a role. |
|
||
| 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. |
|
||
| 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. |
|
||
| 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. |
|
||
| 7 | **The command path is not durable and has no idempotency.** | `rpc.rs`: an in-memory pending map, a flat 10-second timeout, three correlation fields, no replay. A command whose ack is lost is indistinguishable from one that never applied. |
|
||
| 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. |
|
||
| 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. |
|
||
| 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. |
|
||
| 11 | **No record of what the website changed in the game.** | The `admin.audit` stream records that an action happened and nothing about what it produced, so nothing can undo it. This is the piece cleanup depends on entirely. |
|
||
| 12 | **ServUO's own event system is not an authoring substrate.** | `SeasonalEventSystem.cs` is a fixed `enum EventType` of nine built-in content packs with an `Inactive/Active/Seasonal` status. It toggles shipped content. Useful as one cheap capability; not a foundation. |
|
||
|
||
### Documentation that disagrees with the code
|
||
|
||
- `docs/website/ARCHITECTURE.md` places the SSE fan-out inside the core backend. It is entirely
|
||
module-uo's, and has been since the module-system Phase 3 cutover.
|
||
- `docs/modules/rust-dryrun.md` finding 4 states the Android app feature-detects against
|
||
`GET /api/v1/public/modules`. It does not — `ShardStreamClient.kt` hardcodes
|
||
`api/v1/public/shard/stream`, a module path, and no Kotlin source mentions `capabilities`. The app
|
||
is UO-coupled today ([§J](#j--integration-map)).
|
||
- `docs/website/ENGAGEMENT.md` §8.6 already carries a row for this system — *"Come back for X — a
|
||
scheduled event is starting · operator-authored · ❌ needs a manual/scheduled trigger type"*. It
|
||
should be resolved by this work rather than left standing.
|
||
|
||
---
|
||
|
||
## B2 — What "event" already means here
|
||
|
||
The word is already carrying three loads. The new one is a fourth, and the collision is manageable by
|
||
qualifier rather than by invention.
|
||
|
||
| Sense | Where it lives | What it is |
|
||
| --- | --- | --- |
|
||
| **Game telemetry** — an "event kind" | the wire, `shard_events`, `shardIngest.js` | ~60 kinds the shard emits. Append-only, content-fingerprint deduped, fanned out over SSE. |
|
||
| **Engagement triggers** | `registerEventTriggers`, `engagement_rules.trigger_id` | "Something happened someone might want telling about", with a typed payload and an audience ceiling. Shares **one namespace** with push stream ids. |
|
||
| **ServUO's own** | `EventSink`, `SeasonalEventSystem` | In-game hooks, and a fixed nine-value toggle for shipped seasonal content. |
|
||
| **New — the authored occasion** | this proposal | A staff-authored, versioned, scheduled, multi-phase production that the system runs, watches and tears down. |
|
||
|
||
**Recommendation.** Keep "Event" as the human word — it is what a shard admin calls the thing, and
|
||
inventing a word for the UI would be worse than the ambiguity. Disambiguate everywhere else by
|
||
qualifier, exactly as `shard_events` and `trigger_id` already coexist: tables are `event_definitions`
|
||
/ `event_runs` / `event_run_steps`, and the runtime's own domain events are core-owned engagement
|
||
triggers under `event.` — `event.run.scheduled`, `event.run.started`, `event.phase.changed`,
|
||
`event.run.completed`, `event.run.cancelled`, `event.run.failed`. That prefix is legal under
|
||
`EVENT_ID` and unclaimed by any of the five core or twenty-six module trigger ids.
|
||
|
||
The one genuine conflation risk is in the module contract, where `registerEventTriggers` (declare a
|
||
game event) sits beside `registerEventActions` (declare a verb). Different id spaces, different
|
||
objects; the docs must say so in one sentence at each registration — the treatment audiences already
|
||
get.
|
||
|
||
---
|
||
|
||
## C — Proposed architecture
|
||
|
||
Core owns the engine; the module owns the meaning. What the codebase adds to that rule is *how* the
|
||
split is expressed — declaration plus dispatch, never a string core interprets.
|
||
|
||
**The seam, in one sentence:** a module declares typed verbs, says what each costs, and acts when core
|
||
asks; core owns whether the action is enabled, when it runs, in what order, how many times, within
|
||
what cap, what it created, and who is told.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph core["CORE — game-agnostic"]
|
||
direction TB
|
||
cal["Calendar & series<br/>schedule · recurrence · arcs"]
|
||
auth["Authoring<br/>definitions · versions · phases · steps"]
|
||
enab["Enablement & 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 & 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](#f--the-module-contract)), so a granted reward is a ledger row like any other, carrying an
|
||
optional `memberKey` so results can name who received it. What can be granted is an option source
|
||
the module answers; for module-uo its labels come from `shard_clilocs`, already imported for the
|
||
market pages.
|
||
- **Points** — still out. No core points system exists and no write path to any loyalty board exists.
|
||
Items are a different thing and they are in ([§G](#g--uo-implementation-plan-the-gap-list)).
|
||
- **A temporary leaderboard entity** — `event_run_participants` ordered by `score` *is* the
|
||
leaderboard.
|
||
|
||
---
|
||
|
||
## E — Runtime model
|
||
|
||
### Lifecycle
|
||
|
||
| Column | Values | Notes |
|
||
| --- | --- | --- |
|
||
| `event_definitions.state` | `draft` · `ready` · `archived` | Editorial, not a workflow. `ready` means a version has been published and the schedule is live; `archived` keeps a definition's history without it ever running again. |
|
||
| `event_runs.status` | `scheduled` · `starting` · `running` · `paused` · `ending` · `completed` · `cancelled` · `failed` · `missed` | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal for a schedule that passed its grace window while the process was down — **never a late silent start**. |
|
||
| `event_runs.health` | `ok` · `degraded` · `stalled` | Separate from status, because a run can be genuinely *running and degraded* — announcements landing, world writes parked — and one column cannot say both. This is `installed_modules`' split. |
|
||
| `event_runs.cleanup_status` | `not_required` · `pending` · `complete` · `incomplete` | Also separate: a run **reaches `completed` with `cleanup_status = 'incomplete'`** rather than being held open, and stays on the admin screen until a human resolves it. |
|
||
| `event_run_steps.status` | `pending` · `running` · `done` · `failed` · `skipped` · `refused` · `cancelled` | `refused` is the cap breach, and it is deliberately not `failed` — nothing is wrong with the system. **A step waiting on a human is `running` with a NULL lease** (Phase 2, below). |
|
||
|
||
### The scheduler
|
||
|
||
One worker, `utils/eventRunner.js`, in the **website** process beside the other seven — not the bot,
|
||
which cannot load module code. Same `setInterval` + `unref()` + `stop()` shape, wired into
|
||
`server.js`. Its tick does three things in order: **materialise** due occurrences per scope with
|
||
`INSERT IGNORE` against the unique key; **advance** runs by claiming `scheduled → starting` and
|
||
evaluating phase conditions; **drain** due steps, checking caps, dispatching, classifying, recording
|
||
resources.
|
||
|
||
**As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died),
|
||
**materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock.
|
||
|
||
**Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's
|
||
recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon**
|
||
(`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the
|
||
occurrence key — so the tick that already made one makes nothing, which is what lets it run every
|
||
fifteen seconds for ever. The second SWEEPS: a run whose instant passed while the process was down
|
||
becomes `missed` rather than starting late and silently.
|
||
|
||
**The two halves need each other, and the horizon is why.** Expansion looks forward from
|
||
`now - grace_seconds` only, so an occurrence nobody ever materialised is never invented
|
||
retroactively — waking up after three days down must not manufacture three days of `missed` history
|
||
that no operator could have seen or cancelled. It does not have to: because rows exist a fortnight
|
||
ahead of their instant, an outage spanning an occurrence finds the row already there and the sweep
|
||
marks it honestly. **The horizon is what makes the missed sweep mean anything for a recurring event.**
|
||
|
||
**Automatic expansion is at the empty scope** (org lead, 2026-09-02). A fan-out across named scopes
|
||
needs a registry of what a scope *is*, which no phase owns yet; inventing one before the module
|
||
contract would be a contract the modules were never asked about. An admin's own
|
||
`POST /admin/events/:id/runs` still takes any scope.
|
||
|
||
Three numbers govern a step, and they live in the runner rather than in a column because no authoring
|
||
surface would ever show them: `EVENT_STEP_MAX_ATTEMPTS` (3), `EVENT_STEP_RETRY_MS` (60 000, flat), and
|
||
`EVENT_RUN_LEASE_MS` (15 minutes). A step's own lease is not one of them — it is computed from that
|
||
action's declared `budgetMs` plus a minute, because a registry that lets an action declare an hour
|
||
would otherwise have its steps reclaimed and re-dispatched fifty-nine minutes before they answered.
|
||
|
||
**A run's status is re-read between steps, not only at the top of a tick** (Phase 3). One tick drains
|
||
up to `EVENT_STEPS_PER_TICK` steps from one run, so without this a pause pressed mid-batch would be
|
||
answered by dispatching another two dozen steps — which is not a pause. It is one indexed read per
|
||
step, against a control whose entire value is that it takes effect at once.
|
||
|
||
**Serial within a phase.** The runner works the lowest-`seq` step of the current phase that is not
|
||
terminal, and does nothing with the one after it until that one finishes. This is the only reading
|
||
under which `core.wait` means anything, and the only one under which a cue can gate what follows it.
|
||
|
||
**Two scheduling decisions the calendar forces.**
|
||
|
||
*Schedules are timezone-aware, and the timezone belongs to the event.* Every EM listing is in the
|
||
shard's local zone — KST, JST, PDT, CDT, CEST — never UTC. A recurrence computed in UTC drifts across
|
||
a DST boundary and puts a Friday-8pm event at 7pm for half the year. Store the IANA zone on the
|
||
definition, compute occurrences in it, store `scheduled_for` as UTC.
|
||
|
||
*Recurrence is a small closed shape, not cron.* `{ kind: 'once', at }`,
|
||
`{ kind: 'weekly', days, time }`, `{ kind: 'monthly', nth, weekday, time }`, `{ kind: 'manual' }`.
|
||
The only cron precedent is in the bot, in another process, with no parser in the server dependency
|
||
tree — and a cron string is the one field an operator cannot proofread. Monthly-nth is not padding:
|
||
the fishing contest on Drachenfels is exactly that shape.
|
||
|
||
**As built in Phase 4.** `time` is `HH:MM` and `days`/`weekday` are English weekday names rather than
|
||
numbers, for the same proofreading reason that rejected cron; `at` is a **local wall clock**
|
||
(`YYYY-MM-DDTHH:MM`) in the definition's own zone, never a UTC instant, because the schedule belongs
|
||
to the event and the instant is derived at materialisation. `days` is normalised into week order, so
|
||
two spellings of one schedule do not show as an edit nobody made in the version history. **`nth` is
|
||
`1..4` or `-1` for "last"** (org lead, 2026-09-02): every month has a first through fourth of every
|
||
weekday, so the closed set has no absent-occurrence case to define, and `-1` is the shape a
|
||
"last Friday" contest actually is — it is not a synonym for `4`.
|
||
|
||
There is **no date library in the server's dependency tree** and Phase 4 did not add one. Node ships
|
||
the full tzdata behind `Intl.DateTimeFormat`, which is the same database a library would vendor a
|
||
copy of and is already what the zone-name check uses. `events/recurrence.js` inverts the
|
||
instant-to-wall-clock mapping by search; it is the one place an occurrence is computed, so the
|
||
runner's expansion and the calendar's forecast cannot disagree.
|
||
|
||
**The two DST rules** (org lead, 2026-09-02), which exist because a weekly 02:30 event in
|
||
`Europe/Berlin` is a thing an operator will really author:
|
||
|
||
- A **nonexistent** local time — the spring-forward gap — steps forward to the first wall clock that
|
||
does exist. 02:30 becomes 03:00, not 03:30: the event happens as close to the authored time as the
|
||
calendar allows.
|
||
- An **ambiguous** local time — the fall-back hour, which comes round twice — takes the **first**,
|
||
at the pre-transition offset.
|
||
|
||
Neither rule ever drops an occurrence: a weekly event happens every week. Both are recorded on the
|
||
run as `detail.dstAdjusted`, so nobody has to rediscover daylight saving at 3am on the last Sunday in
|
||
October.
|
||
|
||
**Publishing is the schedule switch, and archiving is how it is turned off.** `ready` is defined as
|
||
"a version has been published and the schedule is live", so a second enabled flag would be another
|
||
answer to a question `state` already answers, and the two would eventually disagree. The expansion
|
||
reads the **published version's** spec, never the definition's working copy: a half-typed recurrence
|
||
an author is midway through must not materialise anything.
|
||
|
||
**Publishing also re-pins the occurrences that have not started** (org lead, 2026-09-02). Every run of
|
||
that definition still `scheduled` with a NULL `started_at` moves to the new version, and the count
|
||
comes back on the publish response. A version pin exists to make a run that **has run** reproducible;
|
||
a run that has not begun has nothing to reproduce yet. Without this an editor's fix would reach none
|
||
of the fortnight already on the calendar, and the only recourse — cancelling each stale occurrence —
|
||
is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occurrence`, so the
|
||
occurrence would not come back on the new version, it would vanish. A run that **has** started keeps
|
||
its pin for ever.
|
||
|
||
### 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](#n--decisions)). The "two instances" column names the *hardest* contender for each row, not
|
||
> the only one: the unique index and the CAS equally protect a tick that runs long while the next one
|
||
> fires, and the lease and its reclaim are what recover a step whose process died mid-dispatch. Both
|
||
> happen with one container. What the single-instance decision changes is the **test**, not the
|
||
> design — P2 does not build the `--scale app=2` rig — so the multi-instance property is true by
|
||
> construction and unproven by experiment. Scaling this deployment, or adopting a rolling deploy that
|
||
> briefly runs two containers, is the trigger to build that rig before anything else.
|
||
|
||
### Idempotency
|
||
|
||
```js
|
||
// Core mints it once, at step materialisation, and it does NOT vary by attempt.
|
||
// A retry re-sends the same key, so the game side can recognise the repeat.
|
||
idempotencyKey = sha256(runId + '|' + stepId) // 40 hex, as shardEvents.dedupeKey does
|
||
```
|
||
|
||
It rides the whole path: core → module `perform()` → sidecar → plugin. Core guarantees stability; the
|
||
module passes it through unchanged; the **plugin** keeps a bounded recent-key set and answers a repeat
|
||
with the original result rather than acting twice. That last part is a new plugin capability and a
|
||
protocol field, and it is non-negotiable before any world write ships — a 10-second timeout on a
|
||
command that *did* apply is otherwise indistinguishable from one that did not.
|
||
|
||
---
|
||
|
||
## F — The module contract
|
||
|
||
One new registry and two new declarations, at MODULE_API **1.10.0**. Additive; no signature changes;
|
||
module-uo's `coreApi: "^1.9.0"` continues to resolve.
|
||
|
||
```js
|
||
api.registerEventBudgets([
|
||
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' },
|
||
{ id: 'uo.bosses', label: 'Boss-tier creatures', unit: 'count' },
|
||
{ id: 'uo.npcs', label: 'Event NPCs', unit: 'count' },
|
||
{ id: 'uo.gate.minutes', label: 'Gate uptime', unit: 'minutes' },
|
||
])
|
||
|
||
api.registerEventActions([{
|
||
id: 'uo.creature.spawn', // <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](#m--phased-implementation-plan) P1). Each registration *adds* what an author can reach for;
|
||
> none of them is load-bearing for the engine, and a module that omits one costs its deployment a
|
||
> capability rather than a boot. This is the posture the whole module contract already takes: a
|
||
> module with no `onBoot` still reaches `started`.
|
||
|
||
### Leases: one more declaration
|
||
|
||
A lease action declares what it can hold and how long it may hold it. Core owns the duration and the
|
||
conflict check; the module owns reading the current value and writing a new one.
|
||
|
||
```js
|
||
api.registerEventLeases([{
|
||
id: 'uo.rate.skillgain',
|
||
label: 'Skill gain rate',
|
||
type: 'float', min: 0.5, max: 5,
|
||
maxDurationMs: 86400000, // core refuses a longer lease at authoring time
|
||
async read() { return { ok: true, value: 1.0 } },
|
||
async apply(v, until) { return { ok: true } }, // `until` goes DOWN THE WIRE
|
||
async restore(baseline, { expected }) {
|
||
// MUST verify current === expected before writing. Mismatch is drift,
|
||
// not an error: answer { ok: false, drifted: true, current }.
|
||
return { ok: true }
|
||
},
|
||
}])
|
||
```
|
||
|
||
### What is contract rather than implementation
|
||
|
||
**Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02).
|
||
Both are ordinary envelope members rather than special cases keyed on an action id, so the runner
|
||
never names a verb, and a module's own long-running action reaches them through the same door core's
|
||
does:
|
||
|
||
```js
|
||
return { ok: true, await: 'human' } // PARK. The step stays `running` with a NULL lease;
|
||
// nothing advances until a human confirms it.
|
||
return { ok: true, holdFor: 300 } // FINISH, and delay what follows by 300s. The pause is
|
||
// the NEXT step's `due_at`, owned by core.
|
||
```
|
||
|
||
`await: 'human'` is what makes the GM cue work, and the NULL lease is load-bearing: the stale reclaim
|
||
only ever takes back a lease that is **non-NULL and expired**, so a cue posted on Friday is still
|
||
waiting on Monday rather than being re-dispatched every fifteen minutes. `holdFor` is what makes
|
||
`core.wait` a no-op at dispatch — a `perform()` that slept would hold its claim for the duration, turn
|
||
a five-minute pause into a five-minute lease, and be re-dispatched by the reclaim, so a long enough
|
||
wait would never end. It is bounded at seven days.
|
||
|
||
**A `holdFor` on the last step of a phase holds the next phase**, rather than meaning nothing. The
|
||
later phase's steps do not exist at that moment — they are materialised on entry — so the instant is
|
||
carried across the boundary and applied to the new phase's first step. Dropping it would make
|
||
"announce, wait five minutes, then the next phase" start the next phase at once, which is a wait that
|
||
silently did nothing.
|
||
|
||
- **Every method answers with an envelope, and no shape a failure can take reads as success.**
|
||
`registerTeamProvider`'s load-bearing rule, inverted: the team provider's default on refusal is
|
||
"keep what you have" because staleness is cheap; an action's default is **"nothing happened,
|
||
retry"**, because the expensive mistake is recording a world change that did not occur. A rejected
|
||
promise, a throw, a timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`.
|
||
- **`cost` is declared by the module and enforced by core, before dispatch.** A module says what a
|
||
verb consumes; core owns the caps and the arithmetic. A module cannot spend a budget it did not
|
||
declare, and cannot under-report to escape a cap without lying in code the operator installed —
|
||
which is the same trust category as its schema fragment.
|
||
- **`risk` is declared and core-enforced, over a closed four-value set.** The values are
|
||
`notify · inspect · change · irreversible` — deliberately *not* "world-read/world-write", which are
|
||
game words a chess ladder has no use for.
|
||
- **Params are validated at save *and* at dispatch, against the declared version.** A step stores the
|
||
`action_version` it was authored against; a bump makes it render a warning in the editor rather
|
||
than dispatch a mistyped parameter. A param's `example` is **required**, on the optional params as
|
||
well as the required ones — the same rule `registerEventTriggers` makes of a variable's example and
|
||
for the same reason. It is the authoring form's placeholder, it is one word at declaration time,
|
||
and it is unreconstructable afterwards; a blank box is how an unattended world write comes to be
|
||
scheduled with a typo in it.
|
||
- **Resources are named by the module and owned by core.** `kind` and `ref` are opaque strings core
|
||
stores verbatim — `ctx.teams.activity.push`'s exact treatment. Core does the remembering; the
|
||
module does the meaning.
|
||
- **`verify: true` must change nothing and must answer honestly.** Dry run is the project's own idiom
|
||
(`installer --verify`, `deploy.ps1 -Verify`) and it is the only way an operator will trust an
|
||
unattended, scheduled world write.
|
||
- **A lease's deadline goes down the wire, and the game side honours it without being asked again.**
|
||
The module passes `until` through to its sidecar and the plugin restores baseline when it passes. A
|
||
module that treats `until` as advisory has produced a lease that outlives an outage, which is the
|
||
one thing a lease exists to prevent.
|
||
- **A module may only advertise a lease it has verified takes effect.** 258 `Config.Get` call sites
|
||
in ServUO split between live reads and values cached at type initialisation, and a lease on the
|
||
second kind applies cleanly and does nothing. Apply, observe, restore — per key, as a test.
|
||
- **`scope` is opaque and may be null.** Core passes it through and never interprets it. Core has
|
||
**no concept of "the game being up"** — only `{ ok: false, retry: true }` — because a module with
|
||
six sidecars cannot answer that question in the singular.
|
||
- **An action whose module is uninstalled goes dormant, never an error.** A step naming it fails
|
||
`terminal` with the module named and the run degrades — never a silent skip. The authoring side
|
||
draws the same line one step earlier, in the shape `engagement_rules` established for a dormant
|
||
trigger: **a step already in a saved spec may keep an unregistered action and a new step may not
|
||
add one**, so an uninstall is never destructive after the fact — and a dormant step blocks the
|
||
*publish*, because a version is what a run pins and a run cannot dispatch a verb nobody registers.
|
||
- **Actions and budgets are their own id spaces.** An action names a verb, a trigger names an event,
|
||
a budget names a resource dimension.
|
||
|
||
### Rewards are not a contract member. They are an ordinary action.
|
||
|
||
An earlier draft had a `registerEventRewards` registry whose entries carried `type`, `cliloc`,
|
||
`itemId` and `hue`. That was four Ultima Online nouns inside a core signature, and it was wrong for
|
||
exactly the reason the rest of this document is careful about. It is deleted.
|
||
|
||
```js
|
||
// module-uo — an item into a backpack. Once given, it is gone.
|
||
{ id: 'uo.item.grant', label: 'Give an item',
|
||
risk: 'irreversible', reversible: 'none',
|
||
cost: () => ({ 'uo.rewards': 1 }),
|
||
params: [{ name: 'item', type: 'string', required: true,
|
||
example: 'PlateChest', source: 'uo.options.items' }] }
|
||
|
||
// module-rust — a VIP group for the weekend. Genuinely a LEASE, not a gift.
|
||
{ id: 'rust.group.grant', label: 'Grant a group',
|
||
risk: 'change', reversible: 'override',
|
||
params: [{ name: 'group', type: 'string', required: true,
|
||
example: 'vip', source: 'rust.options.groups' }] }
|
||
```
|
||
|
||
**Four things this collapse buys, and one it required.**
|
||
|
||
- **Rewards are optional by construction, not by an omitted registration.** A module that declares no
|
||
such action simply has none, and core has no reward concept sitting empty.
|
||
- **The implementation varies freely between games, because core never sees it.** UO grants an object
|
||
into a backpack. Rust might grant a kit, a permission group, currency through an economics plugin,
|
||
or a cosmetic — and **some of those are revocable**. A weekend VIP group is a *lease*, with the same
|
||
deadline and drift check as any other. Fixing every reward at `reversible: 'none'`, as the deleted
|
||
draft did, would have made that inexpressible.
|
||
- **The cliloc catalog becomes an implementation detail of module-uo's option source,** where it
|
||
belongs, rather than a field in a core contract. Clilocs name items and `item_id` pictures them —
|
||
already imported for the market pages, already offline — but **cliloc → ServUO type is not a
|
||
function**: many types share a label and a label constructs nothing. So module-uo's
|
||
`uo.options.items` answers from a plugin-declared constructible allowlist and dresses each entry
|
||
with the label and icon it already holds. Core sees `[{ value, label }]`, as for every other option
|
||
source.
|
||
- **`uo.creature.spawn` needs no catalog at all.** `shard_spawn_creatures.slug` is documented as the
|
||
*"slugified class name"* — roughly 800 constructible types with display spellings and facet
|
||
distribution, re-derived from the shard's own tree on every boot. The atlas is already the
|
||
allowlist.
|
||
- **The one thing it required:** a resource may carry an optional `memberKey`, so core can record an
|
||
outcome against a participant and answer "who received what, from which step, in which run". One
|
||
optional field on a shape that already exists — not a registry.
|
||
|
||
### Param option sources
|
||
|
||
A `source` names a module-served option endpoint, so an authoring field is a dropdown of *real*
|
||
landmarks and creatures rather than a text box an operator can typo. The module answers
|
||
`[{ value, label, group? }]`, core caches, and a refusal degrades the field to free text with a
|
||
visible warning rather than blocking the form. For module-uo it is answered entirely from the spawn
|
||
atlas — offline, with the shard down. Without it the authoring UI in [§I](#i--admin-ui-authoring-and-the-calendar)
|
||
is a JSON editor with better fonts.
|
||
|
||
### Integration Kit
|
||
|
||
A fifth chapter, obeying the kit's own rule — teach and link out, re-specify nothing. The four things
|
||
a second module's author will get wrong are the envelope's failure default, the idempotency
|
||
passthrough, recording a resource *before* confirming it, and under-declaring `cost`. All four are
|
||
one paragraph each and all four are invisible until an outage.
|
||
|
||
---
|
||
|
||
## G — UO implementation plan: the gap list
|
||
|
||
Scoped to the capability set the two UO programs demonstrate are worth having. Nothing below assumes
|
||
a capability exists.
|
||
|
||
**Classification key:** ✅ already exists · 🔗 composable from existing · 📦 new module capability ·
|
||
🔧 new ServUO plugin capability · 📡 new bridge protocol message · ⛔ not practical / excluded
|
||
|
||
| Capability | Class | Evidence & notes |
|
||
| --- | --- | --- |
|
||
| Broadcast a system message | ✅ | `POST /admin/broadcast` → `BridgeAdmin.OnBroadcast`. Behind `AdminWriteEnabled`, length-capped, `actor`-attributed, audited both directions. |
|
||
| Town-crier line / in-game news article | ✅ | `POST /towncrier` and `POST /news`. Re-posting an id replaces it, which makes a phase-scoped announcement naturally idempotent. |
|
||
| Detect deaths | ✅ | `player.death`, `player.murdered`, `mob.killed`. |
|
||
| Detect arrival at a venue | ✅ | `region.enter` with `from`, `to`, `map` and the actor — enough to open a gathering when enough people have shown up. |
|
||
| Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. |
|
||
| Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. |
|
||
| Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. |
|
||
| Detect a boss defeated | 🔗 | Inferable from `champ.update` going `bossUp` true → false plus `mob.killed`. Workable and slightly fragile; a dedicated `champ.boss.killed` kind is a small honest addition worth doing alongside anything else on the wire. |
|
||
| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. |
|
||
| Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. |
|
||
| Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. |
|
||
| Temporary decoration lockdown | 🔧 📡 | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. |
|
||
| Named, hued creatures from an allowlist | 🔧 📡 | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. |
|
||
| "Simple" boss variants | 🔧 📡 | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. |
|
||
| **Lease a live config value** — rates, toggles, caps | 🔧 📡 | `Server/Config.cs` is a runtime typed key-value store, so this works — **for keys read live**. The plugin ships a verified allowlist, because a lease on one of the `static readonly` keys applies cleanly and does nothing. The single most transferable action in the whole set. |
|
||
| Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. |
|
||
| Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. |
|
||
| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. |
|
||
| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. |
|
||
| Idempotent command application | 🔧 📡 | Nothing on the wire carries an idempotency key and `rpc.rs` has no replay. **Required before any world write ships.** |
|
||
| Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. |
|
||
| Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. |
|
||
|
||
**What the reopening actually costs.** Nine new plugin verbs, one protocol bump carrying an
|
||
idempotency key and a lease deadline, and a participation ledger. Bounded work, and every verb is
|
||
cap-bounded, ledgered and attributable by construction. **None of it is `[add`.** The distinction that
|
||
makes it defensible: an event may **own** what it creates and **borrow** what it changes — and a
|
||
borrowed value carries a deadline the game itself enforces. What it may never do is change something
|
||
with no baseline recorded, which is exactly what `[set` is.
|
||
|
||
---
|
||
|
||
## H — Rust / Oxide compatibility
|
||
|
||
The `rust-dryrun.md` exercise already settled the shape: an Oxide plugin dialling out to a per-server
|
||
sidecar, one sidecar per server, the module holding several clients, core never learning there is
|
||
more than one. Nothing here contradicts it.
|
||
|
||
> **The lease is the primitive that travels, not the spawn.** "Double gather rate for the weekend" is
|
||
> the canonical Rust community event, and it is *exactly* lease-with-expiry. Convars, spawn population
|
||
> multipliers, decay rates, night length, forced events — nearly all of Oxide's surface is
|
||
> set-a-value-and-restore. Spawning creatures at a landmark is UO-shaped; holding a value for four
|
||
> hours is **every game**. For a platform aimed at private servers, UO first and Rust after, the
|
||
> contract should be built around the lease and treat object creation as the special case.
|
||
|
||
| Concern | UO | Rust via Oxide |
|
||
| --- | --- | --- |
|
||
| Action verbs | `uo.creature.spawn`, `uo.npc.place`, `uo.gate.open` | `rust.prefab.place`, `rust.zone.open`. **Different verbs entirely, and that is correct** — the contract never asks two games to expose the same actions. |
|
||
| **Leases** | live config keys, spawner rates, seasonal toggles — a plugin-verified allowlist | convars, population multipliers, decay and gather rates — live by default, which makes Rust the *easier* case. Same declaration, same deadline-down-the-wire, same drift check. |
|
||
| Budget dimensions | creatures, bosses, NPCs, gate-minutes | prefabs-placed, zone-minutes. Core holds `{dimension, consumed, cap}` and never learns either vocabulary. |
|
||
| Phase conditions | ~60 telemetry kinds, some polled on a sweep | Oxide publishes real hooks — kill attribution, entity death and proximity arrive as events rather than being diffed. |
|
||
| Participation | The hard part | Substantially easier — hooks carry attacker and victim. |
|
||
| **Rewards** | An item into a backpack. `reversible: 'none'` — once given it is gone. | A kit, a permission group, currency via an economics plugin, a cosmetic. **Several of those are revocable**, so a Rust reward may be `reversible: 'override'` — a weekend VIP group is a lease with a deadline, not a gift. Core sees the difference as one enum value it never interprets. |
|
||
| Several servers | One shard | `run.scope` is in the run's unique key, so one definition fans out to six servers without colliding with itself. Caps are per-run, so a fan-out to six servers is six separate budgets rather than one shared pool. |
|
||
| Wipes | Never | Monthly, and a wipe invalidates every ledgered resource for that server at once. The revert contract must accept `{ ok: true, detail: 'resource no longer exists' }` — "gone, and that is fine" is a successful revert. A wipe also resets leased values to their defaults, a second reason restore must be idempotent. |
|
||
| Identity | In-game `[link` code | Steam — still `rust-dryrun` finding 1's open gap. Events neither closes it nor depends on it: `event_run_participants` carries a module-opaque `member_key`. |
|
||
|
||
**The agnosticism is provable, not merely asserted.** Make `event_definitions.owner_module` nullable
|
||
so an event composed only of core actions — announce, cue, wait, publish — runs on bare core with
|
||
**zero modules installed**. If the engine runs with no game present, it demonstrably contains no game
|
||
vocabulary. That is the same acceptance test the module contract itself uses, and `check:modules`
|
||
already fails core's build on a UO identifier, so it is enforced in CI rather than in review.
|
||
|
||
---
|
||
|
||
## I — Admin UI, authoring and the calendar
|
||
|
||
| Screen | Pattern it reuses | What is new |
|
||
| --- | --- | --- |
|
||
| **Calendar** — month and list view, filtered by state, 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. |
|
||
|
||
**A calendar entry is one of two things, and Phase 4 draws the difference rather than stating it.**
|
||
A **run** is a real `event_runs` row: it has a status, a pinned version and a console, and somebody can
|
||
cancel it. A **projection** is arithmetic beyond the materialisation horizon — no row, nothing
|
||
committed, nothing to open. The API says which each is (`kind`), and the UI draws a projection dashed
|
||
and dimmed, because an operator acting on a forecast as though it were a booking would have been
|
||
misled by the screen rather than by the server. **A projection is never emitted for an instant a run
|
||
already occupies**, which keeps the fortnight inside the horizon from being drawn twice — and which
|
||
also means a **cancelled** occurrence does not reappear as a forecast looking like it is still coming.
|
||
Filtering by run `status`, or by a named `scope`, suppresses projections entirely: a forecast has no
|
||
status, and automatic expansion happens at the empty scope.
|
||
|
||
**The grid's date axis is the reader's timezone; each entry's time is the event's.** §E gives the zone
|
||
to the event because every listing this replaces is written in the shard's local zone — but "what is
|
||
happening this month" is a question about the month the person reading is living in. So the cell an
|
||
event lands in is the reader's date, and the time beside it always carries the event's own zone
|
||
(`20:00 Europe/Berlin`), which misreads as nothing. The server returns UTC instants and does not guess
|
||
the reader's zone; the client places them.
|
||
|
||
**Series are managed on the calendar**, inline, because the calendar is what makes an arc visible in
|
||
the first place. Their writes are `admin, editor` rather than `admin`: naming an arc is authoring, and
|
||
[§N2](#n--decisions)'s narrow gate is about committing the deployment to a run. A series **delete** is
|
||
a real delete — the only one in this feature, where a definition is archived instead. A series pins
|
||
nothing and no run references one; `event_definitions.series_id` is `ON DELETE SET NULL`, so its
|
||
definitions survive without an arc and re-attaching one is a dropdown. The response says how many were
|
||
detached, because that is the entire consequence of the act.
|
||
|
||
**The phase editor should be a timeline, not a node graph.** A canvas is the obvious thing to reach
|
||
for and it is wrong here for a reason that comes out of the code: **the condition grammar has no
|
||
branching.** It is `and`/`or`/`not` over comparisons, bounded at depth 5, deliberately closed, and
|
||
that is all the expressive power this project has ever handed an operator. A canvas would promise
|
||
power the engine does not have. A vertical timeline — phases in order, each with its steps, its
|
||
advance condition, its cap draw and its failure policy — says exactly what the engine can do.
|
||
|
||
**Core renders no game word, and it is already enforced.** Every label in the step editor comes from
|
||
the registration: the action's `label`, its params' names, its option sources' labels, its budget
|
||
dimensions' labels. `check:modules` already fails core's build on a UO identifier.
|
||
|
||
### 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. **Both halves exist as of Phase 3**: the action parks
|
||
the step in Phase 2, the run console's confirm ends it.
|
||
- **Live controls that are honest.** Pause, resume, skip a step, force a phase advance,
|
||
cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event
|
||
is not one of them (see [Versioning](#versioning-and-editing-a-live-event)).
|
||
|
||
> **Six of those exist as of Phase 3, and two do not — for reasons, not for scope.** Pause, resume,
|
||
> cancel, and a step's confirm, skip and retry are built and gated to `admin` + `moderator`.
|
||
> **`advance` — force a phase forward — is not**, because it has no honest meaning yet: a phase today
|
||
> advances when its steps go terminal, and the per-step skip already does that one step at a time.
|
||
> Phase 5 gives a phase an advance *condition*, and that is the first moment "force it anyway" names
|
||
> something an operator could predict. **Cancel takes `{ reason }` and not `{ cleanup }`**, because
|
||
> the resource ledger a cleanup would work over arrives in Phase 8; a flag that changes nothing is
|
||
> the "control that answers 200 and does nothing" this plan has refused twice already.
|
||
|
||
> **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a
|
||
> paused run is paused *at* that step — so re-queueing without resuming would leave the run in
|
||
> precisely the state it was already in, with a second button the operator now has to find.
|
||
> Splitting them would read as honesty and behave as a trap. The single action re-queues the step and
|
||
> resumes the run, and its log line says both.
|
||
>
|
||
> Two guards make it safe. The step must be `failed`, and it must be **the furthest step its phase has
|
||
> reached** — `MAX(seq) WHERE status <> 'pending'`. The near miss is worth recording because the
|
||
> obvious rule is the wrong one: "the lowest step that is not settled" looks equivalent and is not,
|
||
> because `nextOpenStep` selects `pending` and `running` only and the runner therefore steps *over* a
|
||
> failed step. Under that rule a phase whose second step failed-and-skipped and whose fifth then
|
||
> failed-and-paused would offer retry on the second, re-queueing a row behind the runner's own cursor
|
||
> where it would sit `pending` for ever.
|
||
>
|
||
> `attempts` returns to zero, and that is not the rule Engagement Phase 14 arrived at being broken.
|
||
> That rule is about *sweeps*: an automatic path that reset a counter made the ceiling unreachable and
|
||
> the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named
|
||
> person deciding once is the thing it is unattended from.
|
||
|
||
**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](#n--decisions): starting a run is `admin` only, while stopping one is not.**
|
||
|
||
| Surface | Role |
|
||
| --- | --- |
|
||
| The calendar, run history, the run console, the diagnostic log | `staff` — the tier gate, nothing added |
|
||
| Authoring and editing a draft | `admin`, `editor` |
|
||
| Publishing a version, **starting a run** | `admin` only |
|
||
| **Live control of a run in flight** — cancel, abort | `admin`, `moderator` |
|
||
| Any step whose action 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. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
|
||
| **A human skips a step** | `skipped`, which is what that status is reserved for. Legal for a `pending` step and for a parked cue; refused for a step with a live lease, and unnecessary for a `failed` one — `nextOpenStep` already passes over it, so resuming the run carries the phase past it. |
|
||
| **A human retries a step** | Only while the run is `paused`, and only on the furthest step its phase has reached. `attempts` returns to zero and the run resumes in the same action; both facts are in the log line with the actor. |
|
||
| **Cleanup itself fails** | The run reaches `completed` with `cleanup_status = 'incomplete'`, the unreverted resources listed and a manual retry offered. It does **not** stay `running` — an event whose world changes are still up is a real state, and pretending the event is in progress hides it. |
|
||
|
||
### The 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\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }`, and gains `cleanup` with the ledger in Phase 8 |
|
||
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force a phase forward — **not built**; it has no honest meaning until Phase 5 gives a phase an advance condition |
|
||
| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | Phase 3. `confirm` resolves a GM cue step; `retry` re-queues the step a paused run is stopped at and resumes it |
|
||
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
|
||
| `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 |
|
||
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) |
|
||
| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen |
|
||
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
|
||
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
|
||
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps |
|
||
| `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](#n--decisions). `verify` sits with `admin, editor` rather than with `start`: a dry run
|
||
> dispatches nothing, and the author who wrote the definition is exactly who should be able to price
|
||
> it against the caps before asking an admin to publish it.
|
||
|
||
> **Two rows above were added by Phase 1 rather than decided in §N**, and both are derived from
|
||
> what the surface needs rather than from a new policy. `GET /admin/events/:id` exists because the
|
||
> list route serves a summary and the editor needs the whole authored tree; `GET
|
||
> /admin/events/series` exists because a definition carries `series_id` and a form cannot offer a
|
||
> value it cannot enumerate. Both are staff reads of data the list route already exposes, so neither
|
||
> widens the surface's reach.
|
||
|
||
**What Phase 1 built, and what it deliberately did not.** Definitions CRUD, publish, archive, the
|
||
version history, the action catalog, the series read and the run reads are live. Every route that
|
||
acts on a run *in flight* — pause, resume, advance, cancel, step skip/retry/confirm, cleanup — is
|
||
absent rather than stubbed, because nothing is in flight until the runner exists: a control that
|
||
answers `200` and does nothing is worse than one that is not there. `verify` and `GET/PUT
|
||
/admin/events/actions` are absent for the same kind of reason — there are no caps to price against
|
||
and no switchboard to serve until the phase that builds them.
|
||
|
||
**Phase 2 added no routes at all.** It is the runner, and a runner has no surface: a published
|
||
definition started through `POST /admin/events/:id/runs` now actually runs, and the run reads that
|
||
already existed render it moving. The controls above are still absent, and they are still Phase 3's —
|
||
the shipped demo of Phase 2 is a run that announces, waits and completes without anyone touching it,
|
||
which is exactly the thing that needs no control. `core.cue`'s confirm is the first of them that has
|
||
something to act on, and it arrives with the console that shows the cue.
|
||
|
||
**Phase 3 added six routes, and they are the live controls.** `pause`, `resume` and `cancel` on a
|
||
run; `confirm`, `skip` and `retry` on one of its steps. All six are `admin` + `moderator` — the one
|
||
gate in this feature wider than `admin`, and deliberately so (§N2). Every one of them is a
|
||
compare-and-set against the status it is allowed to act from rather than a read-then-write: the
|
||
runner ticks every fifteen seconds, so a console rendered thirty seconds ago describes a run that has
|
||
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
|
||
interrupt. A refusal is a `409` naming the status the run is actually in.
|
||
|
||
Still absent, and still for reasons rather than for scope: `advance` (no advance condition until Phase
|
||
5), `cleanup` and cancel's `cleanup` flag (no resource ledger until Phase 8), `verify` and `GET/PUT
|
||
/admin/events/actions` (no caps to price against and no switchboard to serve until Phase 6).
|
||
|
||
Two response fields arrived with the console and are worth naming because both are **derived, not
|
||
columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST
|
||
can say so — a cue nobody notices is a run that never advances while looking perfectly healthy from
|
||
the outside. A step carries `parked`, a boolean, because the console has to tell a cue waiting on a
|
||
person from a step some process is mid-dispatch on, and it must not do so by being shown
|
||
`claimed_by` and `claim_expires_at`: those are the runner's business, and a UI that reasoned about
|
||
leases would be a second opinion about who owns a row.
|
||
|
||
A module registers actions server-side and adds **no routes** for them beyond its option endpoints,
|
||
which is what keeps the browser from being able to name a transport.
|
||
|
||
---
|
||
|
||
## Observability
|
||
|
||
An administrator must be able to answer *"why didn't phase 3 start?"* without reading server logs —
|
||
and there is a piece of reuse here better than it first looks. The condition grammar already carries
|
||
**human labels**, written for the rule editor's dropdowns: `eq` is *"is"*, `gte` is *"is at least"*,
|
||
`present` is *"is present"*. The same labels render the diagnosis.
|
||
|
||
```
|
||
Phase 3 — "The Boss" has not started.
|
||
waiting on: uo.champ.boss_up where region is "Yew"
|
||
seen so far: 0 of 1
|
||
since: 20:31:04 (28 min)
|
||
caps: uo.creatures 24/30 · uo.bosses 0/4
|
||
last related event: uo.champ.started at 20:31:04
|
||
```
|
||
|
||
| Recorded | Where |
|
||
| --- | --- |
|
||
| Definition edits, publishes and manual transitions, with actor | `event_run_log` + `activity_log` |
|
||
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
|
||
| Cap draws and refusals, per dimension | `event_run_budget` + log |
|
||
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
|
||
| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
|
||
| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` |
|
||
|
||
One caution carried over from the engagement retention work: the run log is high-cardinality and grows per event, so it needs a
|
||
retention sweep from the start — `engagementRetentionPrune` is the pattern, and the rule it learned is
|
||
that only *terminal* rows are eligible.
|
||
|
||
---
|
||
|
||
## M — Phased implementation plan
|
||
|
||
> **The plan of record is [`EVENTS_PLAN.md`](EVENTS_PLAN.md)**, which decides order: what lands in
|
||
> which pull request, what each ships on its own merit, how each is proved, and the traps in each.
|
||
> It supersedes the summary below, which is kept because it is the shape the design argued for and a
|
||
> reader of this document should not have to leave it to see the arc. Where the two disagree, the
|
||
> plan is the one that has been checked against the repos.
|
||
|
||
Seventeen PR-sized slices (P0–P16) on an `edge` branch with one cutover, matching the discipline every
|
||
previous workstream used. Phases 1–10 and 13–16 ship a complete, useful system that schedules,
|
||
announces, orchestrates and records — **without creating or changing anything in the game world** —
|
||
and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **§N1 was answered on
|
||
2026-09-01 and the gate is lifted.**
|
||
|
||
| Phase | | Repos |
|
||
| --- | --- | --- |
|
||
| **P0** ✓ | Design of record; §N answered 2026-09-01; `ADMIN_CONTROLS.md` §8 amended | `docs` |
|
||
| **P1** ✓ | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` |
|
||
| **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` |
|
||
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
|
||
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
|
||
| **P5** | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
|
||
| **P6** | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
||
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
|
||
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
|
||
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
|
||
| **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |
|
||
| **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 `edge` → `main` | all |
|
||
|
||
**Three defensible stopping points**, in case scope has to shrink. **After P5:** a scheduled,
|
||
multi-phase, condition-advanced engine that announces through core's own actions and cues a human for
|
||
anything in-world — roughly a third of the work, and a real product. **After P10** — the recommended
|
||
minimum viable scope — adds the module seam, the ledger, the first UO actions and every notification
|
||
channel, and needs **no answer to N1 at all**. **After P14:** everything, including the world verbs.
|
||
P15 and P16 are not optional if anything ships to `main`; they are the cost of the `edge` discipline.
|
||
|
||
---
|
||
|
||
## N — Decisions
|
||
|
||
**All eleven were settled by the org lead on 2026-09-01**, before any code, which closes the second
|
||
half of [`EVENTS_PLAN.md`](EVENTS_PLAN.md) Phase 0 and lifts the ⚠ gate from P11 and P12. Each entry
|
||
below keeps the recommendation it was decided against, so the reasoning survives alongside the answer.
|
||
**Nine went the way the recommendation argued; two did not** — N2, where starting a run became
|
||
`admin`-only, and N4, where the deployment is declared single-instance for good.
|
||
|
||
Two answers have consequences outside this document, and both are written where they will be found
|
||
rather than only here:
|
||
|
||
- **N1** is an amendment to [`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) §8, recorded there
|
||
as a dated amendment with its reasoning. §8's locked scope is reopened in two places, not one — see
|
||
the decision below.
|
||
- **N3** closes [`ENGAGEMENT.md`](ENGAGEMENT.md) §7.1 Q6, whose "no evaluator yet" row is updated in
|
||
the same pull request.
|
||
|
||
| | Question | Decision |
|
||
|---|---|---|
|
||
| N1 | Scope of the §8 reopening | **As recommended, including item grants** — §8 is amended in two places |
|
||
| N2 | Role split across the four surfaces | **Starting and publishing are `admin` only**; a moderator gets live control of a run already in flight and nothing more |
|
||
| N3 | Runner as the `kind: 'scheduled'` evaluator | **Yes** — one scheduler, not two |
|
||
| N4 | More than one app instance? | **No, and not later** — single-instance is a recorded assumption; the claim paths stay, the two-instance test is not built |
|
||
| N5 | Live console: poll, or core SSE? | **Poll** in v1 |
|
||
| N6 | Module-declared risk classes? | **No** — four closed values, core-owned |
|
||
| N7 | Plugin-side runtime and phase persistence | **The shard stays stateless about events**; reconciliation-on-reconnect reunites objects with phases |
|
||
| N8 | One event invoking another? | **Not as a feature** — composition covers it |
|
||
| N9 | Does core know a reward is a reward? | **No** — a reward is an ordinary action with its own cap dimension |
|
||
| N10 | Who verifies the lease allowlist? | **The plugin, at boot** — set, read back, restore; a key that does not take drops itself from the catalog |
|
||
| N11 | Caps per run, or also per period? | **Per run only** |
|
||
|
||
**N1 · Confirm the scope of the §8 reopening.**
|
||
The direction is settled; what needs writing down is the boundary. Recommendation: permit two things
|
||
and nothing else — **event-owned creation** (creatures, NPCs, gates, decoration, item grants), all
|
||
cap-bounded and deleted at teardown; and **leased mutation** of a plugin-declared allowlist,
|
||
baseline-recorded, drift-checked and deadline-enforced by the game itself. Keep permanently excluded:
|
||
loot-table changes, and arbitrary `[set` / `[get` / `[add` — which is precisely "change something with
|
||
no baseline recorded". This should be an amendment recorded in `ADMIN_CONTROLS.md`, not a silent
|
||
reversal.
|
||
|
||
> **Decided 2026-09-01 — as recommended, item grants included.**
|
||
>
|
||
> **`ADMIN_CONTROLS.md` §8 is reopened in two places, and the amendment says both out loud.** §8's
|
||
> locked scope cut the Tier-H verbs "entirely — not now, not later", and that list names **item/gold
|
||
> grants** as well as world creation. Permitting an event to grant an item is therefore not a widening
|
||
> of §8's creation cut; it is a second, independent reversal of a decision that was taken deliberately,
|
||
> and an amendment that mentioned only the first would be the silent reversal §8 deserves not to get.
|
||
>
|
||
> **What makes the grant a different proposition from the one §8 refused** is the four properties it
|
||
> did not have then, all of which this document requires before P12 ships: it is *declared* by a module
|
||
> rather than typed as a command, *cap-bounded* by `uo.rewards` in a conditional `UPDATE` a stolen
|
||
> session cannot talk its way past, *ledgered* in `event_run_resources` with the run, step and actor
|
||
> that produced it, and *idempotent* on the wire so a lost acknowledgement cannot double it. §8's
|
||
> objection was to an unrecorded change with no baseline; a recorded, bounded, attributable one is a
|
||
> different argument. It remains `reversible: 'none'` — a granted item is not clawed back — which is
|
||
> exactly why the cap and the ledger carry the whole weight here and are non-negotiable.
|
||
>
|
||
> **Still permanently excluded, and this decision does not touch them:** loot-table changes, and
|
||
> arbitrary `[set` / `[get` / `[add`. Those are the "change something with no baseline recorded" case
|
||
> and no cap makes them safe.
|
||
|
||
**N2 · Confirm the role split across the four event surfaces.**
|
||
Settled in principle — gate on the existing roles, add no permission system — so what is left is the
|
||
mapping, and it is a judgement call rather than a design one. Recommendation in [§K](#k--security-model):
|
||
viewing is the staff tier gate; authoring is `admin` + `editor`; publishing, starting and live control
|
||
are `admin` + `moderator`; anything above `notify` and the action switchboard are `admin` only. The one
|
||
worth a second look is whether a moderator should be able to start a run at all.
|
||
|
||
> **Decided 2026-09-01 — no, and the recommendation was changed because of it.** Publishing a version
|
||
> and starting a run are **`admin` only**. A moderator keeps **live control of a run already in
|
||
> flight** — cancel and abort — and nothing more.
|
||
>
|
||
> **The asymmetry is the point.** Starting is the act that commits the deployment to everything the
|
||
> definition contains, unattended, up to every cap it declares; cancelling is the act that stops it.
|
||
> One of those wants the narrowest possible gate and the other wants the widest, because the moderator
|
||
> role exists for incident response and the incident here is "the event is doing something wrong at
|
||
> 2am". Gating the stop button on the same role as the start button would be the version of this that
|
||
> reads consistent and behaves badly.
|
||
>
|
||
> This is the one place the split departs from the `requireRole('admin','moderator')` /
|
||
> `requireRole('admin')` shape module-uo uses, and [§K](#k--security-model)'s table is the normative
|
||
> statement of it.
|
||
|
||
**N3 · Does the event runner also become the `kind: 'scheduled'` trigger evaluator?**
|
||
Recommendation: yes. It closes ENGAGEMENT §7.1 Q6, and a second timer doing the same shape of work is
|
||
how a codebase acquires two schedulers that disagree.
|
||
|
||
> **Decided 2026-09-01 — yes, as recommended.** [`ENGAGEMENT.md`](ENGAGEMENT.md) §7.1 Q6's row is
|
||
> updated from "no evaluator yet" in the same pull request as this decision. The evaluator itself is
|
||
> built by P2, which is the phase that acquires the tick.
|
||
|
||
**N4 · Is this deployment ever more than one app instance?**
|
||
Affects nothing in the design — every claim is already multi-instance safe — but it decides how hard
|
||
P2's tests must work, and whether the answer is "safe" or merely "documented".
|
||
`docker compose up --scale app=2` is how the engagement workstream's Phase 13 acceptance walk found the seed race.
|
||
|
||
> **Decided 2026-09-01 — one instance, and not planned to change.** The `app` service runs as a single
|
||
> container; there is no load balancer, no rolling deploy with two containers briefly alive together,
|
||
> and no intent to add either. **P2 does not build the two-instance test.**
|
||
>
|
||
> **Every claim in [§E](#concurrency) stays exactly as specified, and this decision is not a licence to
|
||
> simplify them.** They are not only there for a second container: the unique index and the CAS are
|
||
> equally what protect a tick that runs long while the next one fires, and the lease and its reclaim are
|
||
> what recover a step whose process died mid-dispatch — both of which happen in a single-instance
|
||
> deployment. They also cost nothing to keep, being one index and one `WHERE` clause.
|
||
>
|
||
> **What this actually buys is a smaller P2 and one honest sentence:** the multi-instance property is
|
||
> *by construction*, not *proved*. If this deployment is ever scaled, or acquires a rolling deploy, the
|
||
> event runner is the component most likely to break and it breaks by duplicating world writes rather
|
||
> than by erroring — so scaling is the trigger to build the test that was skipped here, and this
|
||
> paragraph is where whoever does that will find out why it does not already exist.
|
||
|
||
**N5 · Does the live run console poll, or does core gain SSE?**
|
||
Recommendation: *poll* in v1. Core has no SSE at all and adding one is a larger core change than this
|
||
needs. If a live channel is ever built it should be core infrastructure with module-uo's two streams
|
||
migrating onto it, not an event-system side effect.
|
||
|
||
> **Decided 2026-09-01 — poll, as recommended.** The run console polls; core gains no SSE in this
|
||
> workstream.
|
||
|
||
**N6 · May a module declare its own risk classes?**
|
||
Recommendation: no. Four closed values, core-owned. A module-defined class is a module choosing which
|
||
gate it sits behind. Budget *dimensions* are the opposite case and are module-declared, because core
|
||
cannot name what a game counts.
|
||
|
||
> **Decided 2026-09-01 — no, as recommended.** Four closed, core-owned risk classes. Budget
|
||
> dimensions stay module-declared.
|
||
|
||
**N7 · Where does the plugin-side runtime live, and does a phase persist across a shard restart?**
|
||
An asymmetry needing a decision: a spawned creature is an object and persists; a *phase* does not
|
||
exist on the shard at all. Recommendation: keep the shard **stateless about events** — it owns objects
|
||
and leases, core owns phases — and make reconciliation-on-reconnect the mechanism that reunites them.
|
||
A plugin that remembered a phase would be a second event engine.
|
||
|
||
> **Decided 2026-09-01 — as recommended.** The shard is stateless about events: it owns objects and
|
||
> leases, core owns phases, and reconciliation-on-reconnect is what reunites them.
|
||
|
||
**N8 · Can one event invoke another?**
|
||
Recommendation: not as a feature. It already works by composition — a second event's trigger condition
|
||
can be `event.run.completed` — and a direct edge would need cycle detection, a depth bound and a story
|
||
about cancelling a parent. Series and arcs cover the storyline case without invocation.
|
||
|
||
> **Decided 2026-09-01 — not as a feature, as recommended.** Composition covers it; there is no
|
||
> direct edge, therefore no cycle detection and no depth bound to design.
|
||
|
||
**N9 · Does core need to know a reward is a reward?**
|
||
Recommendation: **no**. A reward is an ordinary action, enabled or not on the switchboard like any
|
||
other, with its own cap dimension — a shard that wants none simply never enables it. An action
|
||
returning a resource that carries a `memberKey` is enough to answer who received what, and inventing a
|
||
category would put core back in the business of knowing what a game can give.
|
||
|
||
> **Decided 2026-09-01 — no, as recommended.** A reward is an ordinary action with its own cap
|
||
> dimension. Core learns no reward category.
|
||
|
||
**N10 · Who verifies the lease allowlist, and how often?**
|
||
A key that is live-read today can become `static readonly` in a later ServUO release, and the failure
|
||
is silent — the lease applies and nothing changes. Recommendation: the allowlist ships with the plugin
|
||
and each key carries a boot-time self-check (set, read back, restore) that drops a key from the
|
||
advertised catalog if it does not take. Better a capability that disappears loudly than one that lies.
|
||
|
||
> **Decided 2026-09-01 — as recommended.** The allowlist ships with the plugin and each key
|
||
> self-checks at boot, dropping itself from the advertised catalog if the write does not take.
|
||
|
||
**N11 · Are caps per run only, or also per period?**
|
||
Recommendation: **per run only**. A cumulative cap that silently exhausts mid-event is a worse failure
|
||
than one that refuses at authoring time, and with no delegation there is nobody whose longer-term
|
||
allowance needs tracking. Per-period is a later addition if it is ever wanted.
|
||
|
||
> **Decided 2026-09-01 — per run only, as recommended.** Per-period caps are a later addition if
|
||
> they are ever wanted; nothing in the schema forecloses one.
|
||
|
||
---
|
||
|
||
## O — Recommendations beyond the brief
|
||
|
||
**The lease is the most reusable thing in this document, and it outlives the event system too.**
|
||
"Hold this value for four hours, then put it back, and put it back even if the website never comes
|
||
home" is a primitive a platform for private servers wants regardless of whether events exist — a
|
||
weekend rate bonus, a maintenance mode, a temporary cap during an incident. It is also the answer to
|
||
the objection behind §8: an unrecorded change is high blast radius, a baseline-recorded, drift-checked,
|
||
self-expiring one is a different argument entirely.
|
||
|
||
**The resource ledger is worth building even if the Event System never ships.**
|
||
A durable, queryable record of *what the website changed in the game and whether it can be undone* is
|
||
the platform's largest missing safety property. Today the moderation write plane records that an action
|
||
happened and nothing about what it produced. It is also what makes the reward audit answerable — who
|
||
received what, from which step, in which run.
|
||
|
||
**Participation attribution is now the largest piece of new UO work.**
|
||
With points cut, spawning is no longer the hard part — attribution is. Results, profile history, the
|
||
calendar's "what happened" and any future recognition all rest on trustworthy "who took part", and UO
|
||
gives no run-scoped attribution that can be synthesised from the website side.
|
||
|
||
**The GM cue step makes the system useful before any protocol change.**
|
||
"Post the instruction, wait for a human to confirm, advance" needs no module, no protocol and no world
|
||
write — and it lets the website orchestrate, schedule, announce, track and audit while a GM does the
|
||
target-driven parts in-client, exactly as §8 argued they should. It is the cheapest thing in this
|
||
document and possibly the highest-value.
|
||
|
||
**Dry run should be a contract obligation, not a UI option.**
|
||
Make `verify` a required parameter of `perform` that a module must honour, with a test in the kit. It
|
||
is the project's idiom in two other tools, and a module that ignores it will not be caught by review.
|
||
|
||
**Rehearsal mode is nearly free and disproportionately valuable.**
|
||
Running for real with announcements ceilinged to `staff` needs no new mechanism. It is the difference
|
||
between finding out phase 3 never advances during a test and finding out during the event.
|
||
|
||
**Build the calendar as a real data model, because the thing it replaces is not one.**
|
||
Series, recurrence, arcs, per-shard local time, categories, scope filters, results. UO's own calendar
|
||
has none of it — a monthly fishing contest and a weekly spy mission are re-entered by hand every time,
|
||
and a three-part storyline is only connected in prose. This is the least glamorous part of the feature
|
||
and probably the part an operator would notice first.
|
||
|
||
**Let events run with no game at all.**
|
||
Nothing in the core engine needs one. A wiki drive, a forum contest, a Discord tournament is a
|
||
definition with announce steps, a schedule, participants and results, and it works on bare core with
|
||
zero modules. It costs one nullable column, it is a genuinely larger product than the brief describes,
|
||
and it is the strongest possible proof that core learned no game vocabulary.
|
||
|
||
**Do not build a node-graph editor.**
|
||
Named explicitly because it is what this feature attracts. The engine has no branching, the condition
|
||
grammar is closed at depth 5, and a canvas would advertise power that does not exist. A timeline tells
|
||
the truth.
|
||
|
||
---
|
||
|
||
*No code was written. Every codebase claim was read from the working trees on 2026-09-01; where a
|
||
document and the code disagreed, both are recorded in [§B](#b--existing-gaps). The two UO programs are
|
||
summarised from uo.com's Player Event Coordinator page and its Event Moderator directory and event
|
||
calendar, read 2026-09-01.*
|