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:
@@ -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`)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
1339
website/EVENTS.md
1339
website/EVENTS.md
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user