Merge pull request 'docs(events): the Event System record — the cutover step 16b missed (edge → main)' (#232) from edge into main

Reviewed-on: #232
This commit is contained in:
2026-09-10 02:54:40 +00:00
12 changed files with 5205 additions and 197 deletions

View File

@@ -1117,6 +1117,190 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
---
### The six event tables — the engine's, game-agnostic (events phase 1)
Design of record: [`EVENTS.md`](EVENTS.md) §D, which is where the full column list of every event
table lives. **Six land in Phase 1** — the ones that do not depend on the module contract — and are
spelled out below. The rest arrive with the phase that gives each a writer, rather than as empty
tables nothing reads: `event_run_phase_gates` in Phase 5, `event_action_settings` and
`event_run_budget` in Phase 6, `event_run_resources` in Phase 8, and **`event_run_participants` in
Phase 10** — `run_id` + a module-opaque `member_key` that is `UNIQUE` together, a nullable `user_id`
that `SET NULL`s so a record of what happened survives an account deletion, a `DECIMAL(18,4)` score
and a `rank_at` written only when results are published. Core writes it and sources none of it: a
`member_key` → account mapping is one game's, and a module reports both halves on its action's
success envelope.
Phase 10 also put two columns on tables that already existed — `event_runs.results_published_at`
(below) and a nullable `announce_jobs.run_id`, which is what lets an event announce a post the news
pipeline has already announced without either job standing on the other's toes. Everything that
means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means
the one with a NULL `run_id`.
Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id,
a `scope`, a resource kind and a budget dimension are opaque strings core stores and never
interprets.
#### event_series — the arc
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | |
| description | TEXT NULL | |
| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags |
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one
arrives with the calendar.
#### event_definitions — the thing that is listed, scheduled and audited
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it |
| summary | VARCHAR(500) NULL | |
| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is |
| image_url | VARCHAR(500) NULL | |
| owner_module | VARCHAR(64) NULL | the module that SHIPPED this definition as content — not the module whose actions its steps call. A definition may call three modules' verbs and belong to none of them; NULL is the ordinary case |
| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to |
| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert |
| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below |
| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | |
| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc |
| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once |
| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 |
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: every listing this replaces is written in the shard's local zone, and a recurrence computed in UTC puts a Friday-8pm event at 7pm for half the year. Validated against the platform's own tzdata via `Intl.DateTimeFormat` |
| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter.
`INDEX(series_id, series_order)` — the arc.
**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.**
"Editing a draft is free; no version exists yet" means the working copy 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 exactly what versioning exists to prevent. Publishing copies this
column into a version and leaves it standing as the next draft.
#### event_versions — the immutable snapshot a run pins
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 |
| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree |
| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| published_by | INT NULL FK→users(id) ON DELETE SET NULL | |
**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the
*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what
makes a run reproducible and an audit answerable after the definition has moved on.
#### event_runs — one occurrence, in one scope
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
| version_id | INT NOT NULL FK→event_versions(id) | **no ON DELETE clause, so it RESTRICTs**: a run whose pinned spec had been deleted could not be explained afterwards, which is the one thing this table is for |
| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence |
| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal |
| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both |
| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it |
| current_phase | VARCHAR(64) NULL | |
| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant |
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation |
| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params |
| params | JSON NULL | |
| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history |
| started_at / ended_at | DATETIME NULL | |
| results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one |
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner |
| started_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| last_error | VARCHAR(500) NULL | |
| created_at / updated_at | DATETIME | |
`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per
occurrence per scope" true.** The claim decides *who* advances an occurrence; this index is what
stops two of them existing. `scope` is inside the key so a worldwide event fans out to many servers
without colliding with itself. Materialisation is `INSERT IGNORE` against it, so asking twice for one
occurrence answers with the existing row rather than raising a duplicate-key error a caller has to
interpret.
`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list
· `INDEX(concurrency_key, status)` the overlap check.
#### event_run_steps — the work queue
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | |
| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted |
| params | JSON NULL | validated against the action's declared params at save |
| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter |
| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows |
| due_at | DATETIME NULL | |
| attempts | INT NOT NULL DEFAULT 0 | |
| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run |
| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat |
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | |
| last_error | VARCHAR(500) NULL | |
| started_at / finished_at | DATETIME NULL | |
| created_at / updated_at | DATETIME | |
`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran
into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan ·
`INDEX(run_id, phase, seq)` the run console.
#### event_run_log — "why didn't phase 3 start?" must be a query
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | |
| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` |
| phase | VARCHAR(64) NULL | |
| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable |
| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds.
**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative
audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did*
goes here. They are different questions with different readers and different retention. The writer
**never throws** — a failure to record why something went wrong must not become a second failure on
top of the first.
**The log is high-cardinality and grows per event, so it needs a retention sweep from the start.**
`engagementRetentionPrune` is the pattern and the rule that work learned is that only *terminal* rows
are eligible; the sweep lands with the runner, and the index it needs is in the DDL from the
beginning.
#### The action registry — declared, never stored
Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables,
deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one
would outlive the module that can perform it. `modules/registries.js` gained
`registerEventActions` in this phase, with core as its first registrant —
`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is
exercised on every boot long before a module uses it.
The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names
a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the
closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only
iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type
and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog
serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is
naming one by id.
`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js`
builds its own `api` facade for a module and has no method that delegates to it, so no module can
call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later
phase's.
---
## 4. API contract
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
@@ -1512,8 +1696,34 @@ file a route sits in — that is the property the route manifest freezes.
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` |
| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders |
| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused |
| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition |
| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins |
| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps |
| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight |
| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` |
| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log |
| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing |
| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase |
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
> **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it.
> The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5),
> the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup`
> (Phase 8), the cap meter (Phase 13) and the public/player reads (Phase 14a) are not listed here.
> **[`EVENTS.md`](EVENTS.md) § API surface is the canonical table** and carries every one of them
> with the reasoning for its gate; re-listing them here would be a second copy of a contract that
> file owns, and the copy that drifts is always the second one.
>
> The Phase 14a reads are the only ones outside this tier: `GET /public/events`,
> `/public/events/:slug` and `/public/events/series/:slug` on the anonymous surface, and
> `GET /player/events/history` on the self-service one. What makes something visible there is
> `listed` **and** `ready` **and** not a rehearsal, and all three are predicates in SQL rather than
> checks a caller performs — a draft, an archived definition and an unlisted one all answer 404,
> indistinguishable from a slug that never existed.
Every admin write logs to `activity_log`.
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)

View File

@@ -1676,7 +1676,7 @@ deferred to this phase on purpose:
| | Question | Decision |
|---|---|---|
| §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle |
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet |
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today. **The evaluator was assigned on 2026-09-01** ([EVENTS.md §N3](EVENTS.md#n--decisions)): the Event System runner becomes it, built in that workstream P2 |
| — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed |
**One namespace was the more invasive of the two options and it is worth stating what it bought and
@@ -4078,6 +4078,14 @@ day it ships.
caller's — so `kind` means something from the moment it is declarable rather than from the moment
it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and
the first thing anyone will ask for after the IDOC mail works.
> **The evaluator now has an owner, decided 2026-09-01** ([`EVENTS.md`](EVENTS.md#n--decisions)
> §N3). The **Event System runner** becomes the `kind: 'scheduled'` evaluator rather than a ninth
> poller being written for it — the runner already materialises due occurrences on a tick, and a
> second timer doing the same shape of work is how a codebase acquires two schedulers that
> disagree. It is built in that workstream's P2. Nothing in this phase's contract changes: a
> `scheduled` declaration means today exactly what it meant on 2026-08-29, and `ctx.events.emit`
> still refuses to fire one.
7. ✅ **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email
marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be
built if they are powered by game data — say team X members or governors or whatever — thru the uo
@@ -4325,7 +4333,7 @@ worth confirming in Phase 4 rather than discovering in Phase 11.
| **Welcome / first steps** after registration | `users.created_at` | ⚠ needs a time-based evaluator | email | delayed (e.g. +1 h) |
| **Finish setting up** — no linked game account after N days | `shard_links` absence | ⚠ module-supplied predicate | email, in-app | delayed |
| **We miss you** — no login in N days | `users.last_login_at` | ⚠ needs a scheduled sweep | email | monthly, hard cap |
| **Come back for X** — a scheduled event is starting | operator-authored | ❌ needs a manual/scheduled trigger type | email, push, in-app | scheduled |
| **Come back for X** — a scheduled event is starting | `event.run.started`, emitted by the event runner | ✅ **built — `EVENTS_PLAN.md` Phase 10**. It is a trigger AND a stream, which is what gives it push: the push channel is stream-keyed, so the six other `event.` triggers are email and in-app only | email, push, in-app | on the run |
| **Your invite is about to expire** | `user_invites.expires_at` | ⚠ | email | delayed |
| **Account dormant, scheduled for cleanup** | policy | ❌ no dormancy policy exists | email | scheduled, staged |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,223 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.9.0'
const MODULE_API_VERSION = '1.10.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8).
Four additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
deployment simply has fewer verbs an event can use — which is §F's own posture stated as a version
rule, because core with none of this installed is still an event engine that can announce, wait, cue
a human and publish results.
> **Phase 8 added `reconcile()` and `ctx.events.reconcile()` to this same version rather than to a
> new one** (org lead, 2026-09-03). A protocol owes a bump once it has landed on `main`; while it is
> on `edge` it is amended in place — the rule the Teams workstream arrived at, applied to a module
> API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as
> one version they read once, which was the argument for putting the lease declaration here in the
> first place.
>
> **Phase 10 amended it a second time, under the same rule**, with `participants` on the success
> envelope and an optional narrowing `ceiling` on `ctx.events.emit`'s. `main` still declares 1.9.0,
> so 1.10.0 remains unshipped and the whole event contract — actions, budgets, leases, option
> sources, reconcile, participants — still reaches an author as one number. The `ceiling` member is
> the one of the two that widens something 1.9.0 already shipped, and it is additive and optional:
> a module that never passes it is emitting exactly what it emitted before.
**Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`,
`core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in —
`loader.js` built its own `api` facade and had no method that delegated to it. So the seam a module
now reaches is one that has been exercised on every boot for six phases, rather than one whose first
registrant is a stranger. That is the same argument `registerCore()` has made since the module
system's Phase 3, and 1.10.0 is when it pays.
```js
api.registerEventBudgets([
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' }, // a DIMENSION core can bound
])
api.registerEventActions([{
id: 'uo.creature.spawn', // <moduleId>.-prefixed; its OWN id space
label: 'Spawn creatures',
risk: 'change', // closed: notify | inspect | change | irreversible
reversible: 'ledger', // closed: none | self | ledger | override
version: 1,
budgetMs: 10000,
cost: (p) => ({ 'uo.creatures': p.count }), // what ONE invocation consumes
params: [
{ name: 'creature', type: 'string', required: true,
example: 'Orc', source: 'uo.options.creatures' }, // a `source` makes it a dropdown
{ name: 'count', type: 'int', required: true, example: 12 },
],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {
if (verify) return { ok: true } // dry run: validate, change NOTHING
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
},
// Required iff reversible: 'ledger'. Called by core's cleanup sweep at teardown,
// over the rows this action's `resources` produced — a LIST, so twelve creatures
// are one round trip. `{ ok: true }` reverts the group; `failed: ['0x...']` names
// the ones that did not come back.
async revert({ runId, resources, idempotencyKey }) { return { ok: true } },
// OPTIONAL, and only on an action that ledgers. "Which of these does the game
// still have?" — asked after something outside core restarted.
async reconcile({ runId, resources }) { return { ok: true, inForce: ['0x40001234'] } },
}])
// The module says WHEN, because core cannot: core has no concept of the game
// being up. module-uo already watches `bootId` to tell a shard restart from a
// sidecar reconnect, and that is the moment a ledger of live spawns has become a
// claim about a world that no longer exists.
ctx.events.reconcile()
api.registerEventOptionSources([{
id: 'uo.options.creatures', label: 'Creatures',
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
}, {
// A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and
// requires it of none, so a resolver that ignores it is unchanged; `searchable`
// is what tells the authoring form to render a typeahead rather than a select.
id: 'uo.options.spawners', label: 'Spawners', searchable: true,
async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) },
}])
// A value a run may borrow. The module ships the three callables; the VERB an
// author puts in a step is core's `core.lease`, so the duration bound and the
// two-events-one-target conflict check live in one place.
api.registerEventLeases([{
id: 'uo.rate.skillgain', label: 'Skill gain rate',
type: 'float', min: 0.5, max: 5, maxDurationMs: 86400000,
async read() { return { ok: true, value: 1.0 } },
async apply(v, until) { return { ok: true } },
async restore(baseline, { expected }) { return { ok: true } },
async inForce() { return { ok: true, held: true } }, // optional
}, {
// A TARGETED lease: one capability over many things. Core adds the target to
// the reservation ref (`<lease id>#<target>`) so two runs may hold the same key
// on two different objects, and hands it to all four callables.
id: 'uo.spawner.maxcount', label: 'Spawner: how many at once',
type: 'int', min: 0, max: 100, maxDurationMs: 43200000,
target: { label: 'Which spawner', source: 'uo.options.spawners' },
async read({ target }) { return { ok: true, value: '3' } },
async apply(v, until, { target }) { return { ok: true } },
async restore(baseline, { expected, target }) { return { ok: true } },
async inForce({ target }) { return { ok: true, held: true } },
}, {
// A string lease may close its value set. `min`/`max` bound the numeric types
// and nothing bounded `string`, so without this the only check on the value is
// the game side's -- a refusal arriving unattended, mid-run, rather than on the
// authoring form.
id: 'uo.seasonal.status', label: 'Seasonal event status',
type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000,
target: { label: 'Which seasonal event', source: 'uo.options.seasonal' },
async read({ target }) { return { ok: true, value: 'Inactive' } },
async apply(v, until, { target }) { return { ok: true } },
async restore(baseline, { expected, target }) { return { ok: true } },
}])
```
**What a module author has to know beyond the four names**, because each is a rule rather than a
field:
- **No shape a failure can take reads as success.** A rejected promise, a throw, a `budgetMs`
timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. **A module that
needs the `retry: false` half of that to be reachable must declare a `budgetMs` longer than its
own transport's timeout** — see §2.4's rule, which exists because the first module to register an
action did not, and its one non-retryable verb was retried anyway. That is
`registerTeamProvider`'s default *inverted*, deliberately: a team provider that refuses leaves core
showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded
as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that
means "this will never work" says `retry: false`.
- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering
`{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything
else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone.
- **A lease that declares a `target` is a family of values, and core changes what it reserves.**
Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a
config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands
of spawners and reserving the id would let one run turning up one spawner refuse every other run
every other spawner. With one, the ref is `<lease id>#<target>`, the two-events-one-target index
bites at the granularity the world actually has, and the target reaches all four callables.
**Core refuses a targeted lease with no target and an untargeted one with a target**, both
`retry: false`: the second attempt has the same params. `target.source` names an option source
for the authoring form, and is not resolved by core at registration — a source registered by a
module that boots later must not make this one throw.
- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the
numeric types; a set on an int lease would be a second bound beside them with no rule about
which wins, so it is refused.
- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this
existed behaves identically. Declare `searchable: true` when the term actually narrows the
answer — the form reads that to decide between a typeahead and a select, and inferring it from a
truncated list would read correctly right up until a small deployment's list happened to fit.
Core needs it because a reconcile after an outage asks *"does the game side still have any record
of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run
applied is drift, which `restore()` reports so the row lands `drifted` with the current value
beside it, and a reconcile that inferred absence from a changed value would orphan the row first
and tell the operator the lease vanished rather than that somebody moved it. The two questions
have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's
step names `core.lease`, which is core's own action, so there is nowhere else a module could hang
the answer.
- **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module
registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because
the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same
as bounding it: a declared dimension with no operator cap is counted and unbounded.
- **`verify: true` must change nothing and must answer honestly.** It rides the same dispatcher a
real run uses, because a dry run down a second code path is a dry run of the second path.
- **`example` is required on every param, optional ones included** — the same rule
`registerEventTriggers` makes of a variable's example, for the same reason: it is the authoring
form's placeholder, it is one word at declaration time, and it is unreconstructable afterwards.
- **Actions, budgets, leases and option sources are four separate id spaces**, each namespaced
`<moduleId>.`. An action names a VERB, a budget a RESOURCE, a lease a VALUE and an option source a
CATALOG, so `uo.creatures` may legitimately appear in more than one — reading that as a collision
would forbid the most natural set of names a module will ever write.
- **A lease is declared by a module and acquired by CORE.** The verb is `core.lease`, and the
module never writes one: core reads the baseline, reserves the target in the resource ledger —
which is where "two events cannot hold one target" comes from, as a unique index rather than as a
check — applies the value with the deadline, and restores it at teardown through the module's own
`restore()`. A lease verb per module would be that bound re-implemented once per module, advisory
everywhere, and wrong in the first one that forgot it.
- **`until` goes down the wire, and the game side must honour it without being asked again.** A
module that treats it as advisory has produced a lease that outlives an outage, which is the one
thing a lease exists to prevent. Core's copy of the deadline is for the console; the game's copy is
the fail-safe.
- **`revert` must be idempotent, and reverting something that does not exist is a SUCCESS.** Core
records a resource BEFORE it is confirmed (`EVENTS.md` §D rule 1), so a dispatch whose answer was
lost leaves a row for something that may never have existed — and cleanup will ask about it. A
module never has to tell "I deleted it" from "it was not there". This is also what a Rust-style
monthly wipe needs, and the second reason a lease's restore must be idempotent too.
- **`revert` is also called with NO resources and only an idempotency key.** That is the lost-answer
case: core knows a dispatch went out under that key and never learned what it made. A module that
can undo by key answers honestly; one that cannot answers `{ ok: false }` and the row stays visible
to an operator, which is the correct outcome rather than a silent one.
- **`reconcile` is optional where `revert` is required**, and the asymmetry is the design. A module
that cannot say what the game still has is not broken — core keeps believing its own ledger, which
is the behaviour before this version — whereas one that created something and cannot undo it has
made a promise core has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }`
leaves the ledger alone: **"I do not know" is never read as "it is gone"**, and a resource a module
reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go.
**A third joined in Phase 15, also on an envelope:**
- **`detail` on an action's SUCCESS envelope** (`EVENTS.md` §F). An optional object a module may
answer with, carried to the run log as a `step.detail` line and **never interpreted by core** —
nothing reads a key out of it in the dispatcher, the runner or the browser. It exists because a
module knows things about its own verb core cannot compute and had no other way to say them:
`uo.item.grant` reaches the players a run's participation ledger holds, and *which of them missed
out* was reported nowhere at all. On both success shapes, like `resources` and `participants`,
because `await: 'human'` is a success and a cue's confirm finishes the step without a second
dispatch.
Objects only, 4KB of serialised JSON, dropped rather than truncated, and **anything wrong with it
is dropped and logged rather than failing the step** — a step that did what it was asked must not
be re-run because its module's commentary was malformed, which would be a world write repeated for
a log line. It is additive and optional: a module that never answers one is behaving exactly as
before. **Found by writing the integration kit's chapter 5** (`EVENTS_PLAN.md` Phase 15), whose
template made the same mistake `module-uo` had — see §F.
**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
written against 1.8.0 keeps working and simply seeds nothing.
@@ -142,6 +352,64 @@ branches lacks — so it is the only place `permits` is true between two values
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
unchanged, and so is every rule about composition narrowing rather than widening.
**1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed
signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions
until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`,
`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Almost nothing
was added to `ctx`: an action is called BY core, so what a module needs from this contract it is
handed in the envelope rather than reaching for — `ctx.events.reconcile()` (Phase 8) is the one
exception, because only the module knows when the game it talks to has restarted.
Two members joined it in Phase 10, both on an envelope:
- **`participants` on an action's SUCCESS envelope** (`EVENTS.md` §D, §J). An action may answer
`{ ok: true, participants: [{ memberKey, userId?, score?, meta?, joinedAt? }] }` and core records
them against the run, on both success shapes, beside `resources`. `memberKey` is required and
module-opaque; `userId` is optional and is the module's own answer to "is this player a website
account", because core cannot map one and a core that guessed would be one game's identity model
compiled into core. A bad entry is dropped and logged, never a retry: a retried step re-dispatches
a world write that already happened.
- **`ceiling` on `ctx.events.emit`'s envelope** (`EVENTS.md` §I). An optional audience ceiling for
THIS firing, which may only ever narrow: the send-time G24 gate applies `meet(declared, emitted)`,
so a rule wider than the meet is refused and one narrower is unaffected. Two incomparable ceilings
meet to null and every rule is refused, which is §5.1a rule 3's posture rather than a guess about
which branch was meant. The case that forced it is core's own — a rehearsal fires the same
lifecycle triggers as a real run and must not mail every subscriber — and it is on the shared
envelope rather than in `events/` because "this particular firing is narrower than the kind
usually is" is a fact any emitter can have.
Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended
into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there
is no deployment that could tell the difference, and the events cutover is what publishes the
whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose
and stays so until the cutover re-pins `ci/core-ref.json`.
- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a
FAMILY of values rather than a single value, and core reserves `<lease id>#<target>` rather than
the id — so two runs may hold the same key on two different objects while two runs holding one
object still collide on the unique index. The target reaches `read`, `apply`, `restore` and
`inForce`. Every lease before this named one value, so the id *was* the target and none of the
four needed an argument; a property does not have that shape.
**The verb stays core's**, which is the whole reason this is an extension rather than a lease
verb of the module's own. §F settled that in Phase 8: a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and
wrong in the first one that forgot. Half of that objection no longer holds — the
two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the
row — and the other half still does.
- **`values` on a `string` lease.** The closed set an author may choose from, checked by
`core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded
`string`, so the only check on a string lease's value was the game side's — a refusal arriving
unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside
`min`/`max` would be a second bound with no rule about which wins.
- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose
catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the
argument answers exactly as it did before this existed, which is what makes it additive. The
first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the
2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says
nothing about which part. `searchable` is declared rather than inferred, because inferring it
from a truncated answer reads correctly right up until a small deployment's list happens to fit.
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and
@@ -489,6 +757,10 @@ api.registerSlashCommands([{ name, description, options, access, handler }]) //
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
api.registerEventBudgets([{ id, label, unit }]) // 1.10.0
api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0
api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -861,6 +1133,88 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio
in `channels`. Every other key must be one of the rule's channels.
- **It is not a send path.** Every value on the object is data. Core still decides who is told.
**`registerEventActions([...])` / `registerEventBudgets([...])` / `registerEventLeases([...])` /
`registerEventOptionSources([...])`** (1.10.0) are the event contract (`EVENTS.md` §F). The full
shapes and the ten rules that come with them are in §1.1 under **1.10.0**; six things are contract
rather than implementation and belong here:
- **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s
dispatch, and unlike everything above them — but from further away than either, because the thing
on the other end may be a shard. That is why `budgetMs` is declared per action and enforced by the
dispatcher: without it a `perform()` awaiting a socket that never answers holds a step's claim
until its lease expires, and the reclaim then re-dispatches it, which is how one wedged sidecar
becomes an infinite loop rather than a failed step.
- **`budgetMs` must EXCEED the timeout of whatever the action talks to** (Events Phase 9). The
dispatcher classifies a budget timeout as `retry` unconditionally and does not ask the action —
it cannot, the action is still awaiting a socket. So an action whose own client gives up *after*
core's deadline never gets to classify its own failure, and `retry: false` in its envelope is
unreachable code. The default `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, which
is the wrong way round: every slow shard produced a retry the module had explicitly refused. The
rule generalises past that one pairing — an action is the near end of a call with a far end, and
the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it
is attempted once, declares 15000.
- **A module's `detail` is carried and never read.** An optional object on either success shape,
bounded at the dispatcher and written to the run log verbatim beside the action id. Core reads no
key out of it — a switch on known keys anywhere in core would be core learning one module's
vocabulary, which is the thing this whole contract exists to prevent. It is the answer to *"what
actually happened"* for a verb whose answer is neither a resource nor a participant, and before
Phase 15 there was no such answer: `EVENTS.md` §H named the member, `classify()` had never read
one, and a module that used it wrote into nothing.
- **A module reports who took part on the envelope, and there is no other door.** `participants`
rides back from `perform()` exactly as `resources` does, on both success shapes — including
`await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is
therefore the only moment its participants can be recorded. There is deliberately no
`ctx.events.participants` and no route: a second write path into a run core is mid-tick on would be
a second thing that can race the step claim. One step may report at most 5000, the same bound the
engagement engine puts on a list of users a caller may assert, and a member reported twice in one
step is recorded once with the duplicate named.
- **A declaration's ceiling bounds the kind of event; a firing's `ceiling` bounds the occasion.** An
emitter that already knows this particular firing must not reach as far as the declaration allows
passes one, and the gate takes the meet. It only narrows — passing a wider value changes nothing —
and passing something incomparable with the declaration refuses every rule rather than resolving
to either. See §1.1 under 1.10.0.
- **`once`, on all four.** A batch is a module's complete statement about what it declares; a second
call is a module changing its mind halfway through `register()` rather than adding to it. And they
STAGE, like every registration above: a module that registers two budgets and then throws has left
nothing behind.
- **Everything is optional, and §F says so once because it governs every member.** A module may
register no actions, no budgets, no leases and no option sources. Each registration *adds* what an
author can reach for; a module that omits one costs its deployment a capability rather than a boot,
exactly as a module with no `onBoot` still reaches `started`.
- **An option source that refuses degrades its field to free text with a warning.** It never blocks
the authoring form and it never raises. The alternative is a screen a module's outage can take
away, for a field whose value the operator very often already knows — which is a worse failure than
the typo the dropdown exists to prevent.
- **Core records what an action made BEFORE the action is dispatched, not after** (`EVENTS.md` §D
rule 1). A module's `resources` are the refs core did not know until the answer arrived; what core
wrote beforehand is a placeholder keyed by the step's idempotency key, so a dispatch whose answer
never came back is still something cleanup can act on. The consequence for a module author is the
whole reason `revert` takes `idempotencyKey` as well as `resources`: it will sometimes be called
with the key and an EMPTY list, meaning *"a command went out under this key and core never learned
what it did"*. Answering that honestly is what makes an unattended world write recoverable; a
module that cannot answer it says so, and the row stays visible to an operator.
- **That key IDENTIFIES a dispatch; it is not a key to send on the undo.** It names the command core
lost the answer to, so the module can ask the game about it. Forwarding it as the outgoing key of
the reverting command is a different thing entirely, and on a game whose at-most-once store keys on
the key alone — as the uo-link shard's does — the undo is then recognised as a repeat of the DO and
answered with the original reply. `module-uo` made exactly this mistake: teardown of all five world
verbs was a no-op that reported success, because every despawn carried the key its spawn had gone
out under. Found by the Phase 16 acceptance walk, with the ledger reading `reverted` and the shard
still holding every object. A command that undoes needs a key of its own or none at all; a repeated
undo is usually harmless by construction ("already gone" is a success), which is what makes *none*
the right answer more often than not.
- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an
action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it
runs on every terminal path — completion, cancellation and abort alike — so a module's only job is
to answer `revert` correctly however many times it is asked.
**An action whose module is uninstalled goes dormant, never an error.** A step already in a saved
spec keeps it and a new step may not add one — the shape `engagement_rules` established for a dormant
trigger — and a dormant step blocks the PUBLISH, because a version is what a run pins and a run
cannot dispatch a verb nobody registers. A step that reaches dispatch naming one fails `terminal`
with the module named and the run degrades: never a silent skip.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -1103,6 +1457,17 @@ Four fields, in the loader's scan order (§4.2). What is *not* there is the desi
string. A client must treat an unknown capability as absent and must not infer a route from one — the
mount prefixes are `module.json`'s business (§2.3), not the capability list's.
**Core publishes a capability list of its own, and it is deliberately not this one.** Since events
Phase 14a, `GET /public/version` — and `GET /public/status`, which embeds the same block — carries a
`capabilities` array naming what CORE serves beyond the baseline every backend has. It is the same
idea and the same word so that a client feature-detects one way rather than two, and a **separate
list** because core is not a module: publishing core here as a pseudo-module would leave a client
unable to tell *"this backend has events"* from *"a module called core happens to be installed"*,
which is exactly the distinction this endpoint exists to make. The value in core's list is in what is
absent — a backend released before a capability existed omits the key entirely, which is how a client
tells an older site from one that simply has nothing to show. The same rule applies to both: an
unknown string is absent, and no route may be inferred from one.
The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own
rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads
the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route