# The Module API — the contract **Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md), **validated by the atlas spike** — Part 7 records what the spike proved, what it changed in this contract, and the three artifacts in it that are not design. This document is the normative contract between the core website and an installed module. `MODULE_SYSTEM.md` decides *what* the module system is; this decides *exactly what a module may call, what it must provide, and what core promises not to break*. Everything below is derived from what the UO code actually does today, re-read against the working tree on 2026-08-10. Where the survey contradicted `MODULE_SYSTEM.md`, the contradiction is recorded in Part 6 rather than quietly resolved — four of them, one of which (OpenAPI, §6.1) needs a decision before Phase 2 starts. **The one rule everything else serves:** a module reaches core *only* through the members named in this document. Zero `require`/`import` from a module to a core file, enforced in CI (§5.1). A core refactor that leaves this contract intact cannot break a module; anything a module needs that is not here extends the contract first, in this file, before the module is written against it. --- ## Part 1 — Versioning ### 1.1 `MODULE_API_VERSION` Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js 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', // .-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 (`#`) 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 `#`, 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 `.`. 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. It exists because 1.7.0 let a module say what an event's payload *is* and gave it no way to say what the message should *read* like. `engagement/templateSeeds.js` and `engagement/coreRules.js` are core files with core arrays in them and there was no registration call beside them, so a module's notification was core's generic `notify.event` body or nothing at all. That is tolerable for one trigger and not for a catalogue: Phase 11 ships twenty-five, sixteen of which have domain prose that core must never contain (§5.2 — and `check:modules` reads identifiers, never prose, so this boundary is honoured deliberately rather than enforced mechanically). ```js api.registerEngagementSeeds({ templates: [{ key: 'uo.house.idoc-warning', // MUST be namespaced "." name: 'House — final decay warning', channel: 'email', // 'email' | 'inapp' subject: 'Thy house at {{region}} stands in peril', triggerId: 'uo.house.idoc_warning', triggerVersion: 2, seedVersion: 1, blocks: [ /* the same block objects the template editor writes */ ], }], ruleGroups: [{ key: 'triggers-v1', // the one-shot guard's name — see below note: 'shown in the boot log when it inserts', rules: [{ trigger_id: 'uo.house.idoc_warning', // MUST be one of this module's own name: 'House decay warning', audience: 'owner', channels: ['email', 'inapp'], template_keys: { email: 'uo.house.idoc-warning', inapp: 'uo.house.idoc-warning-inapp' }, cooldown_seconds: 86400, max_sends_per_hour: 200, delay_seconds: 900, // optional cancel_on: ['uo.house.refreshed'], // optional conditions: null, // optional }], }], }) ``` **Callable once per module, and validate-then-commit like every other registration.** A module that got one of thirty templates wrong ships none of them and finds out at boot with the offending key named, rather than at send time with a half-seeded table. **The two halves behave differently, and the asymmetry is the contract.** - **Templates are re-ensured on every boot.** Each row carries `seed_key`, `seed_version` and `customized`, so re-ensuring is how a better default reaches a deployment *without* stealing an operator's edit (§4.6.1 property 3 of `ENGAGEMENT.md`), and a template added in a later module version reaches every deployment rather than only fresh ones. **Bump `seedVersion` when a body changes; never for a comment.** - **Rules are one-shot, per named GROUP.** Re-ensuring a rule would resurrect one an operator deleted and reset one they enabled, so each group carries its own settings guard (`engagement_module_rules_seeded::`). **A rule appended to an existing group therefore reaches fresh installs ONLY** — never a deployment already stamped. A rule that must reach existing deployments takes a **new group key**. The module names its groups, so the module makes that choice; make it knowingly. **Three things a module may not do, each of which is a shipped mistake that would only surface as mail somebody received.** 1. **A seeded rule is always `enabled = 0`.** `enabled` is not a parameter — a value passed for it is ignored rather than refused, because refusing would let a typo take a module offline at boot. This is `ENGAGEMENT.md` Q3's invariant surviving the largest seed set in the workstream. 2. **A module may not mark a template `protected`.** That flag means *"the system breaks without this body"*, which is true of a password reset and of nothing a module ships; setting it would take an operator's delete button away. 3. **A rule may only name its own module's `trigger_id`**, and its `template_keys` may only name this module's own seeds or **core's** (`notify.event`, `inapp.event`, `notify.digest` — which is §4.6.1 property 1 in force, and the right answer for any trigger whose message is structural). A template `key` must be namespaced `.`, because `engagement_templates.key` is UNIQUE across the table and an unprefixed `notify.event` from a module would collide with core's and win or lose on boot order. **When it runs, which is not where the other seeders run.** `server.js` calls `seedDefaults()` **before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader — so at the moment core seeds its own templates, no module has registered anything. Module seeds are therefore written from `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and **before** the `onBoot` dispatch. Two consequences worth relying on: a module the operator **disabled** (or one that failed to load) is skipped rather than seeded, and a module that warms a cache in `onBoot` may assume its bodies and rules already exist. Failure is logged and swallowed like every other step there — a body that would not seed costs the shipped default, never the boot. **It is not a send path.** Everything on the object is data; nothing on it is a function and nothing on it names a recipient. A module still cannot mail anyone (§1.2): it declares who an event is *about*, and core decides who is told, after the ceiling, the preferences, the suppression list and the verification gate. **1.8.0 — a seventh audience ceiling: `admin`** (`website/ENGAGEMENT.md` Phase 11, decision 1). One addition and no removal, so minor; every declaration valid under 1.7.0 is valid now and no stored value changes. `admin` is a **child of `staff`**, so a module may declare `ceiling: 'admin'` on a trigger or an audience and a `staff`-ceilinged trigger accepts an `admin` audience as a narrowing. It exists because the narrowest role-shaped value the lattice had was `staff`, which means **admin, editor AND moderator**. Phase 11's operator-facing triggers — a digest of what staff did in game, the economy thresholds, the world-save counts — are admin-audience everywhere they are described, and ceilinging them at `staff` would have let an operator save a rule that mails the staff audit digest to every moderator in it. **What a module author has to know beyond the new name.** `admin` is the **only pair in the whole lattice with real containment** — every admin is staff, which is exactly what every other pair of branches lacks — so it is the only place `permits` is true between two values below `authenticated`. `permits('staff', 'admin')` holds; `permits('admin', 'staff')` does not, and neither direction holds 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 `#` 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 `api.registerEngagementSeeds({...})` (§2.4) · `ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3). **This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked — *a contract owes a bump only once it has landed on `main`* — was true when they were written and is not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has only ever been on `edge`" are **historical, not current**. Everything added from here takes a version of its own. That is also why the integration kit does not go red until the engagement cutover: `ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover window (`ENGAGEMENT.md` Phase 13). **As in 1.6.0, the number states the whole surface and the members arrive by phase.** `ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7); everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled on: a member of a declared version that were simply absent would make the version a lie, and one that silently accepted data into a table that does not exist would be worse than either. **One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot attach a payload contract to another module's stream and cannot claim a stream id another module has declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment (`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See `registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision. **1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and `ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` · the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` · `registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it. > **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an > optional `{ core }` naming which of core's contributions belongs in the declared place, and core > offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by > the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction > and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the > inverted direction worked for exactly one module and silently did nothing for any other, which the > integration kit found while trying to teach it to an audience outside this org. > > **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes. > > **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member, > `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and > 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not > anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where > a Team page actually is, so a notification email could name a Team and not link to it. See > `registerTeamProvider` below. > > **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol > 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has > only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing. > > **The client slots `team.overview` and `team.member.row` are replaced by the INVERTED direction.** > Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a > surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own > the word for one, so the module that owns the vocabulary owns the page. In their place, > `registry.declareModuleSlot(id, name, { core })` lets a MODULE declare a place on its own page for > CORE to fill, and `Slot` joins the UI kit so the module can render it. See §3.7a. **Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A module may call any member of this version and get the behaviour documented below. **`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every existing one is either the module claiming a mount or core notifying it; the closest precedent is `registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes the envelope, the 10-second budget and the refusal semantics contract rather than implementation — they are how a module says "I cannot answer" without core hearing "there is nothing". **1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop — `'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always written by hand (§3.4). Found by the acceptance run in [`../modules/kit-acceptance.md`](../modules/kit-acceptance.md): a module built by following the kit alone rendered *outside* the site's page column, because the wrapper's two class names belong to `theme.css` and appear in no contract. Omitting `shell` is 1.4.0's behaviour exactly, so core's own pages are untouched. **Minor, and the table below is why that needs saying.** "A member's signature changes" is a major bump, and a prop is a signature — but the rule is about a call that *already exists* changing meaning, and an optional prop changes none. Read the table as being about what breaks, not about what is typed: adding an optional argument is an addition, and `module-uo`'s `coreApi: "^1.3.0"` still resolves. **1.4.0 — Phase 5, the sidecar rule.** §2.7 gained one prohibition: a module does not open a connection to a game server from the website process. It talks to a **sidecar**, which owns the durable copy of the game's state. No member was added, removed or changed — the surface is identical to 1.3.0 — and the bump exists because a module written against 1.3.0 could conform to every member and still be built the wrong way round. It is **minor rather than major** deliberately: nothing that satisfied 1.3.0's *surface* stops working, `module-uo`'s `coreApi: "^1.3.0"` still resolves, and module-uo already complies because the sidecar is where it came from. The reasoning a module author needs is [`../../Integration-kit`](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) chapter 3; the rule itself is below, because the kit teaches and never re-specifies. **1.3.0 — Phase 3 slice 3, the client half's move.** Three client additions, each because the extraction needed it: a nav item may carry an **`icon`** component (§3.3), core declares a third slot **`player.invite.accepted`** (§3.7), and `window.__rg.api` gained **`BASE`** — which §3.5 specified from the first draft and `shared.js` had never actually published, because nothing needed it until a module had to build an EventSource URL. Additions only; the server half is untouched and bumps because the two halves state ONE version. **1.2.0 — Phase 3, the client half.** `registry` gained `registerExtension` and core gained client extension slots (§3.7). An addition only, and the first change to `window.__rg` since 1.0.0 — the server half is untouched, and both files bump because the two halves state ONE version. **1.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and `middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`. Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an admin action a module performs belongs in core's one audit log, the extension slot needs the user its prefix names, §2.7 forbids a module reading core's `APP_BASE_URL`, a second rate-limit store is a limit enforced by two counters, and core's CMS was calling a UO file directly. Every `module.json` declares a `coreApi` semver **range**. The loader checks it at boot, before it requires a line of module code, and a mismatch fails that module loudly into `startup_failed` (§4.4) with the two versions in the reason. It never silently proceeds. | Change | Bump | | --- | --- | | A member is added to `ctx`, or a new `register*` call appears | minor | | An **optional** prop or argument is added to an existing member | minor | | A member is removed or its signature changes | major | | Behaviour of an existing member changes without a signature change | major | | A core-internal refactor behind an unchanged member | none | This is a **separate number from `PROTOCOL_VERSION`**, which versions the shard wire and has nothing to say about a website module. It is also separate from the module's own version. ### 1.2 What is *not* contract Core's internal file layout, table names, middleware ordering, the `api` client object's shape, and every component under `client/src/components/` except the ones named in §3.4. A module that reaches any of these is out of contract even if it happens to work. --- ## Part 2 — The server contract ### 2.1 `module.json` Read synchronously by the loader from `modules//module.json`. Unknown top-level keys are rejected rather than ignored, so a typo is a loud failure and not a silently-inert setting. ```json { "id": "uo", "name": "Ultima Online", "version": "1.0.0", "coreApi": "^1.0.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", "purge": "server/db/purge.sql", "mounts": { "public": ["/shard", "/atlas"], "admin": ["/shard", "/uo-link"], "player": ["/shard"] }, "extensions": ["admin.users.detail"], "capabilities": ["shard", "atlas", "market"] } ``` | Key | Required | Meaning | | --- | --- | --- | | `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, the `window.__rg` registry key. Must equal the directory it was read from. | | `name` | yes | Human label for the admin Modules screen. | | `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. | | `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). | | `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. | | `client.entry` | no | Prebuilt ESM chunk, relative to the module root, and **in a subdirectory** — the directory it sits in is what gets served (§3.1). Absent ⇒ server-only module; present-but-empty is rejected, since it claims a client half and delivers none. | | `schema` | no | Idempotent SQL fragment (§2.6). | | `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. | | `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. | | `extensions` | no | Core extension slots this module mounts into (§2.4). | | `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules` (§2.9), for clients (the SPA, the Android app) to feature-detect against. Published only while the module is `started`. | ### 2.2 The entry point `server/index.js` exports a single function. It is called once, synchronously, during `app.js` require — **not** after the database is up. ```js module.exports = function register(ctx, api) { /* … */ } ``` It must not `await`, must not touch the database, and must not throw for a reason that a retry would fix. Everything that needs a live database belongs in `onBoot` (§2.5). This constraint is not stylistic: `scripts/routeManifest.js` and `swagger/swagger.js` both require `app.js` with the pool pointed at a dead port, and a module that queried at registration time would hang both. ### 2.3 `ctx` — what core hands the module Every member below exists because a UO file uses it today. Nothing is speculative, and nothing that module-uo does not need is on the list. | Member | Signature | Backed by | First real caller | | --- | --- | --- | --- | | `ctx.express` | the `express` namespace | core's `node_modules` | every module router (§7.2) | | `ctx.validator` | the `express-validator` namespace | core's `node_modules` | `atlas.router.js` | | `ctx.db.query` | `(sql, params?) => Promise` | `utils/db` | every `*.db.js` | | `ctx.db.pool` | mariadb pool | `utils/db` | `shardAtlas.db.js` (streamed import) | | `ctx.log` | `(namespace) => { error, warn, info, debug }`, each `(msg, meta?)` | `utils/logger` | all nine UO utils | | `ctx.settings.get` | `(key) => Promise` | `model/settings` | `shardAtlas.model` | | `ctx.settings.set` | `(key, value, updatedBy?) => Promise` | `model/settings` | `shardAtlas.model:60` | | `ctx.settings.getInstanceName` | `() => Promise` | `model/settings` | `shardIngest.js:84` | | `ctx.auth.getUserFromRequest` | `(req) => { id, username, role } \| null` | `utils/auth` | `shardVisibility.js` | | `ctx.push.publish` | `(streamId, { ref?, ownerUserId? }) => Promise` | `utils/pushDispatch:92` | `shardIngest.js:22` | | `ctx.secretBox` | `{ encrypt(s), decrypt(s) }` | `utils/secretBox` | `uoLinkConfig.model` | | `ctx.middleware` | `{ requireAuth, requireRole, siteMode, validate, noindex }` | `auth/session.middleware`, `middleware/*` | every UO router | | `ctx.uploads` | `{ upload, UPLOAD_DIR, MIME_EXT }` | `admin/imageUpload.js` | atlas art import | | `ctx.posts` | `{ listAll, getById, linkAnnounceJob, markAnnounced }` | `model/posts` | `newsGump.js:108`, `announceWorker.js:58` | | `ctx.paths.moduleRoot` | absolute path to `modules//` | loader | atlas art, cliloc files | | `ctx.activity.log` | `({ req, action, detail }) => Promise` | `model/activity` | every admin UO controller (1.1.0) | | `ctx.users.getById` | `(id) => Promise` | `model/users` | `usersShard.controller` (1.1.0) | | `ctx.site.baseUrl` | getter, string with no trailing slash | `APP_BASE_URL` | `shardAnnounce` (1.1.0) | | `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) | | `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) | | `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks | | `ctx.teams.publish` | `(event) => Promise` | `model/teams/teamSync` | the Team provider's module (1.6.0) | | `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) | | `ctx.teams.activity.push` | `(items) => Promise`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) | | `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) | | `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) | **`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with `api.registerEventTriggers` (§2.4). It is the push half of the engagement seam (`website/ENGAGEMENT.md` §5.2). ```js ctx.events.emit('uo.house.idoc_warning', { subject: '0x40001234', // optional — else read from the declared subjectKey data: { house: 'The Silver Anvil', decayStatus: 'Greatly' }, ownerUserId: 812, // optional — the module resolves it; core never sees a game account dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters occurredAt: new Date(), // optional, defaults to now }) ``` Six things about it are contract rather than implementation: - **A module emits its own triggers and nothing else.** The owner is bound by core from the calling module's id and is never read from the arguments. Without that, `emit` would be a way to fire another module's event with a payload of your choosing, and every rule an operator wrote against that trigger would fire on it. - **The payload is validated against the declaration at EMIT, not at render.** A missing `required` variable or a wrong type is **thrown in development and dropped-and-logged in production** — the posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a game-event handler, and a contract problem of core's must not become the module's control flow. Undeclared keys are dropped rather than rejected; they could never be interpolated anyway. - **It returns `undefined` and never throws in production.** There is nothing a module could correctly do with a delivery failure from inside an event handler, so there is nothing to await. - **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game account is and must not learn; the module maps its own account to a user and passes the result. - **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls back to the variable the declaration's `subjectKey` names. - **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused. **`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's inbox directly without going through a rule. It is **present and throws** until the in-app channel lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent. **`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers* questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a `getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the module already holds, in its own store. **`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is: ```js { externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? } ``` Four things about it are contract rather than implementation: - **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know, and a core that templated it would have re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are likewise opaque — core filters on them and only the module's `team.overview` slot renders anything richer than the text. - **A Team is named by the module's own `externalId`**, which core maps, and only that module's ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an archived Team is not writable — its feed is a closed record of what happened before the rename. - **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core enforces it on the read path. - **It never throws at the call site and never rejects.** This is called from inside a game-event handler, and a storage problem of core's must not become the module's control flow. A malformed item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a sidecar reconnect backfill safe to replay. Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership change visible at once; `reconcile` is a debounced *request* that returns immediately and never rejects. Correctness comes from reconciliation either way, so neither can make a module's own call site slow or turn a background failure into the module's error. The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`, `team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because leadership is its own authority path: a leadership change has to be expressible without pretending someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry `memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one from a delta either, because an archive driven by a message that may simply have been repeated is destruction on no evidence. Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate: - **`ctx.auth` is one function, not `utils/auth`.** The facade also re-exports `signToken`, `setAuthCookie` and the TOTP challenge primitives. Minting sessions is core's job; a module that needs an identity needs to *read* one. - **`ctx.settings` is three functions, not the model.** The model exports 24 names, most of them registration and app-links policy that is core's business. It said *game-signup* policy too until Phase 3 slice 3, which is when that turned out to be wrong in both directions: the setting's own help text names Bridge.cfg, so it was never core's — and slice 1 had shipped a ported controller calling `settings.isGameAccountSignupEnabled()`, which this narrowing does not expose, so `POST /player/shard/account` answered 500 for every caller until slice 3 found it. A narrowing is only as safe as the tests that cross it. - **`ctx.posts` is four functions.** `create`/`update`/`remove` are the CMS, not a module's. And one addition the spike forced: **`ctx.express` and `ctx.validator`**. A module lives at `/modules//`, outside `server/`, so Node's resolver never reaches `server/node_modules` and `require('express')` from a module simply fails — which is how this was found. Even where it resolved, a second express in the process is a second `Router` prototype. Core owns one express, as it owns one React (§7.2). `ctx` is frozen (`Object.freeze`, one level deep) before it is handed over. That is a guard against accident, not against a hostile module — per `MODULE_SYSTEM.md` §2.2 the boundary is organisational, not a security boundary. ### 2.4 `api` — what the module registers The second argument. Every call is synchronous, idempotent-free (calling twice is an error), and validated at once rather than at first use. ```js api.registerRoutes({ public: {...}, admin: {...}, player: {...} }) api.registerExtension(slot, router) api.registerNotificationStreams(streams) api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.registerPostHook({ onSaved, onDeleted }) api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0 api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 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 () => {}) ``` **Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's shape is checked at the call, so a malformed one throws with the registrant's own stack; whether the name is *taken* can only be answered once the batch is complete, and is checked when the loader commits it in its second pass. The consequence is the one that matters: a module that registers two streams and then throws — or fails `checkDeclared` after `register()` returns — has left nothing behind. A half-registered catalog would be worse than a missing one, because it is a subscribable stream nothing will ever publish to. This is the registry-side twin of §4.3's second-pass mount rule, and both exist for the same reason. **`registerRoutes(mounts)`** — one `express.Router()` per prefix per tier: ```js api.registerRoutes({ public: { '/shard': shardRouter, '/atlas': atlasRouter }, admin: { '/shard': adminShardRouter, '/uo-link': uoLinkRouter }, player: { '/shard': playerShardRouter }, }) ``` The keys must match `module.json`'s `mounts` exactly. Prefixes are validated `^/[a-z0-9][a-z0-9-]*$` — one segment, no nesting, no parameters — and rejected on collision with core's own mount table or with another module's, at registration time. The router is mounted *inside* the tier, so it structurally cannot reach above its prefix. **The tier gate is already applied.** A router registered under `admin` sits behind `noindex, isLoggedIn, requireRole('admin','editor','moderator')` from `router/v1/admin/index.js`; under `player`, behind `noindex, requireAuth`; under `public`, behind nothing, by design. A module adds per-route gates on top of that and never re-implements the tier gate. **`registerExtension(slot, router)`** — the §1.9 case: module routes hanging off a *core* resource. Only core may declare a slot; a module may only fill one. Exactly one slot exists in v1: | Slot | Mounted at | Declared by | | --- | --- | --- | | `admin.users.detail` | `/api/v1/admin/users/:id` | `router/v1/admin/users.router.js` | The router receives `req.params.id` from the parent (`mergeParams: true`). Two modules filling the same slot is a collision and is rejected; core's own routes on the resource always win a path conflict. **`registerNotificationStreams(streams)`** — §1.8's push catalog. An array of `{ id, label, description, personal, requiresLinkedAccount }` appended to core's catalog. Ids are namespaced `.` and rejected otherwise, save for the seven grandfathered ones in §6.4. Two amendments this signature carries, both settled 2026-08-10 with PR 4: - **`mapEvent` is gone.** The earlier signature took `{ streams, mapEvent }`, with core's dispatcher calling `mapEvent(event) => streamId`. That was a leftover from before §1.8's push inversion was settled: the module owns `fromShardEvent` outright and calls `ctx.push.publish(streamId, …)` with an id it has already resolved, so core never needs a second way to get there. What core wants from a module here is the catalog — for the subscribe endpoint, for validating a subscription write, and for the personal/linked-account gate. It follows that the public-safety filter (a sensitive event kind can never produce a *public* push) is module-internal; that is the right home, because the kinds, the streams and the filter are then one file that moves together, rather than a rule in core about data only the module defines. - **Two booleans, not one `scope`.** The entry shape above is the response body of `GET /auth/me/notifications/streams`, which a shipped Android client already reads (`NotificationsDto.kt`). `scope` was never the wire shape. **`registerAnnounceLeg({ leg, label, dispatch, classify })`** — §1.8's news dispatcher. `leg` is a namespaced id, `label` is what the admin panel shows, `dispatch(post) => Promise` delivers, and `classify(result) => { outcome, error }` maps the client's result to `done` / `retry` / `terminal`. A leg that throws is caught, classified as a retry, and never blocks another leg. `label` is an addition: the panel used to hold a client-side `{ towncrier, discord }` label table, which would have left a module's leg rendering as a bare id. It comes from the registration so a module needs no client change. **Legs are rows, not columns.** `announce_jobs` carried a `towncrier_*` and a `discord_*` column group until PR 4; a module cannot `ALTER` a core table, so a registered leg had nowhere to live. The per-leg state moved to `announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)` and `leg` is a stored value. The parent `status` rollup is over *all* the job's legs — done when every leg delivered, failed when every leg gave up, partial in between; and `done` when a job has no legs at all, since nothing is left to deliver. **`registerPostHook({ onSaved, onDeleted })`** — added in API 1.1.0. Core's CMS is the only writer of posts, and a module may need to mirror one somewhere core knows nothing about. `onSaved` receives `{ post, transition }` — the same transition `registerAnnounceLeg` fires on — and `onDeleted` receives `{ post, id }`. Both are optional; a registration with neither is refused, since it is a subscription that can never fire. One hook per registrant. Every hook is awaited and none may throw past core: a subscriber's failure is logged and costs neither another subscriber nor the save itself. A sidecar hiccup breaking a post edit would be a worse bug than a stale mirror. **It is deliberately not part of `registerAnnounceLeg`**, which fires on the same transition. A leg is a one-shot *delivery* with retry and classification; a post hook maintains idempotent *state*, has to run on delete as well as save, and refreshes silently on an edit. Overloading the leg would have meant a `dispatch` that must not be retried and a `classify` that means nothing. Before it existed, core's post controller required `utils/newsGump` directly — core's publish path naming a UO file, and the last thing binding core to the module. **`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The module becomes the authoritative source of Teams for this deployment. Two further members, `projectRoster` and `pageUrlTemplate`, are optional and documented below. **One provider per deployment.** Unlike every other registry, this holds a single value: Teams have one authoritative source by construction, and two modules answering "what Teams exist" would produce two disjoint sets under one table with no rule for merging them. A second registration is a collision, reported against the module that holds it. Three methods are required — a provider that could list Teams but not their members would leave core holding Teams it can never populate, which is not the same as a call that fails. The fourth is optional; see below. ```js getTeams() // () => Promise<{ ok, complete?, teams }> getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }> getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey] projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3) // => Promise<{ ok, members }> // members = [memberKey] pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method // e.g. '/uo/guilds/{externalId}' // authoritative { ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] } // the module knows it cannot answer — sidecar down, cache cold, boot not finished { ok: false, reason: 'sidecar unreachable' } ``` A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be core reading a module's table by name. **Every method returns an envelope, never a bare array, and this is the load-bearing part of the contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate `{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is the one a module returns while its sidecar is still connecting. A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and surfaces it. It never empties a roster on an answer it does not trust. **`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It answers *who is allowed to look at a roster*, on the request path, because the audience model and its configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question, "keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to whoever asked. So this one call **fails closed**. Core distinguishes two refusals, and a module does not have to do anything to get the right one: - **no provider, or a provider without `projectRoster`** — there is no audience model to consult and nothing is being withheld, so core serves the roster whole at its own public shape. This is what makes the member genuinely optional: bare core, and a module with no rungs of its own, both render the page core writes. - **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core serves an empty roster and says so in the response (`projected: false`, `projectionUnavailable: true`). **`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary owns the page. That is settled and right, and it leaves core unable to write the link a notification email needs — an email about a forum reply that cannot take you to the thread is most of the way to useless. So the module that owns the page says where it is. ```js api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders, pageUrlTemplate: '/uo/guilds/{externalId}' }) ``` Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only** — a template naming its own host is refused at registration, since there is no reason for a module to redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the member costs the deployment clickable links in Team notification email and nothing else. **Data rather than a callback, deliberately.** A function here would put a module hook on the mail path — one more thing that can hang or throw between a forum reply and the mail about it — to produce a string that never varies. Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for an anonymous caller — and never the `users` row, which would make every column of that table part of this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a published row looks like and re-normalises whatever comes back through its own public shape, so a module can narrow which rows appear and cannot widen which fields do: the member key and the site account id are withheld from every public roster whatever a module returns. `complete: false` means "valid but partial": core applies additions and updates and performs **no** removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony. **A malformed row fails the whole call rather than being dropped.** One unreadable member quietly omitted from a roster is indistinguishable, downstream, from that member having left — core would mark them departed on the strength of a broken payload. Refusing costs one interval of staleness. **`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both belong to the module, live since phase 7 (TEAMS.md §7.1). ```js api.registerSlashCommands([{ name: 'guild', // lowercase, 1-32, no dots description: 'Show a guild on this shard', // 1-100 characters options: [ // the restricted schema, below { name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false }, ], access: 'everyone', // 'everyone' | 'linked' | 'staff' async handler({ command, options, actor }) { return { title, text, fields, url, ephemeral, notice } // every field optional }, }]) ``` **The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules` volume and cannot load a line of module code, so it pulls the definitions over an internal API and owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality, follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding a Discord handle, and this split is the reason a second platform could implement the same contract. **`actor` is resolved by core before the handler is entered**, and is the whole of what a handler learns about the caller: | field | | | --- | --- | | `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees | | `platformUserId` | the caller's id on that platform | | `guildId` | the platform community the command was run in, or `null` | | `userId` | the site account, or `null` when the platform identity is not linked | | `role` | that account's role — a module with audience rungs needs more than a boolean | | `isLinked` | whether `userId` resolved | | `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means | A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an operator-chosen slug. **`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side permission default from it where the platform can express one; core re-checks it in the dispatcher on every call. `'linked'` has no Discord equivalent at all — there is no "has a website account" predicate — so it is simply not advertised, which is exactly why the client half cannot be the boundary. **The option schema is deliberately small: `string | integer | boolean | user`,** each with `required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete, attachments, modals or component interactions — those are the features whose semantics do not survive a second platform. A command needing them is a bot-side command, written in the bot. **A definition the platform would reject fails at `register()`**, not at the next connection: the bot registers the whole set in one call, so one bad option type would cost every command, the bot's own included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option types, and the ordering rule that a required option may not follow an optional one. **Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core cannot see. **A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it. **A disabled module's commands stop answering immediately.** Registration has no removal path — a claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who switches a module off does not leave a live handler behind it. **`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves, what a rule is written against, and what a template may interpolate (`website/ENGAGEMENT.md` §4.3). ```js api.registerEventTriggers([{ id: 'uo.house.idoc_warning', // .-prefixed, one namespace with stream ids label: 'House approaching collapse', description: 'A player house dropped into a late decay stage.', kind: 'event', // 'event' | 'scheduled'; default 'event' subjectKey: 'house', // which variable identifies the cooldown subject audience: 'owner', // the DEFAULT a rule is created with ceiling: 'owner', // the widest a rule may EVER be given version: 1, // bumped on a rename or a type change variables: [ { name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }, { name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' }, ], }]) ``` Six things about it are contract rather than implementation: - **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across both facets. A module may declare both for the same id — that is one event with a subscription toggle *and* a payload contract, and it is what core does with its own five — but it may not attach a contract to another owner's stream, and the refusal names the holder and the facet. The seven grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams, because under one namespace they are the same ids. - **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a), and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated` would silently widen a staff-only event. The seven values are `everyone`, `authenticated`, `subscribers`, `members`, `staff`, **`admin`** (1.8.0, a child of `staff`) and `owner`, ordered by **containment and not by size** — a `staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. `staff` → `admin` is the single true refinement in the tree and the only pair below `authenticated` that `permits` accepts. A default `audience` wider than, or incomparable with, the ceiling is refused at registration. - **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and test-sending a template possible without a live game event, which is the reason template systems go untested. A variable without one is refused. - **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href. - **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`, which looks like the feature working right up until two subjects share it. - **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type change; a template records what it was authored against and renders with a warning rather than interpolating `undefined`. A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by `npm run engagement:manifest` and gated in CI with `--check`. **`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it. ```js api.registerAudiences([{ id: 'uo.team.members', label: 'Members of a team', params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only ceiling: 'members', resolve: async (params) => [/* user ids */], }]) ``` Four things about it are contract rather than implementation: - **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not become the back door that lets it — core maps ids to addresses on its own side, after preferences, suppression and the verification gate. - **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label and a `resolve` it may call. The same boundary `registerNotificationStreams` holds. - **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a *different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it does return are filtered to positive integers before core uses them. - **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all and the save is refused rather than guessed. Composition UI is Phase 4's. **Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing predates them. **`registerEngagementSeeds({ templates, ruleGroups })`** (1.9.0) ships the module's own message BODIES and its seeded rules (`ENGAGEMENT.md` Phase 11b, decision 7). It is the content behind the two declarations above: they say what an event IS and who it is about, and this says what the mail reads like. Callable once per module. The full shape, the three prohibitions and the boot-path placement are in §1.1 under **1.9.0**; four things are contract rather than implementation and belong here: - **Templates re-ensure; rule groups are one-shot.** A body is offered again on every boot under `seed_key` / `seed_version` / `customized`, so improving a default reaches deployments that did not edit it. A rule is offered ONCE per named group, because re-offering would resurrect a rule an operator deleted and reset one they enabled. **A rule appended to an existing group reaches fresh installs only** — that is not a limitation to work around, it is the guarantee; a rule that must reach existing deployments takes a new group key. - **A seeded rule is always disabled.** `enabled` is not a parameter. An operator turns a module's mail on; installing a module never does. - **Core's generic bodies are a first-class answer.** A rule may point `template_keys` at `notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a bespoke body when the message has something to say that the structural projection cannot; a trigger whose message is "this happened, here is the link" should not have one. - **`digest` is a slot, not a channel.** It names the body the digest worker renders for a rule whose email channel an individual set to digest mode, so it is legal in `template_keys` and never appears 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 ``` (core schema + seed) → MODULES resolved onto the volume ← §2.7.2 decision 4 ↓ require(module) → register(ctx, api) → [routes mounted, app.js require returns] ↓ (server.js: schema fragments, then) onBoot(ctx) → started ↓ (SIGINT/SIGTERM) onShutdown() ``` `onBoot` is where the eight `server.js` UO call sites go (`MODULE_SYSTEM.md` §1.7): the atlas and cliloc refreshes, the market display-name backfill, `uoLinkSocket.start()`, the sidecar probe. It runs **after** `ensureSchema()` (so the module's own tables exist) and after `seedDefaults()`, and **before** the HTTP listener binds — a module that must not serve traffic before it has warmed its cache gets that for free. `onShutdown` runs before anything core owns is closed — the database pool, the push dispatcher and the SSE fan-out are all still open, because a module's `onShutdown` is the only chance it gets to flush through them. Reverse registration order, with a 5-second budget per module; exceeding it is logged and the hook abandoned rather than hanging the process. Abandoned, not cancelled: nothing can stop a promise that is still running, but the process is exiting anyway and the alternative is a host where `systemctl stop` waits for SIGKILL. **`onBoot` has no budget, deliberately.** Shutdown races the process being killed; boot does not. A slow `onBoot` delays the listener binding, which is the guarantee two paragraphs up rather than a problem to be timed out, and core's own boot steps are awaited exactly the same way. Both hooks are optional, and both are individually try/caught. An `onBoot` that throws marks that module `startup_failed` (§4.4) and the site still comes up — its routes stay mounted but its dispatch guard rejects them with 503, because a module that failed to warm up serving half-initialised data is worse than a module that says it is down. A module with no `onBoot` at all still reaches `started`: having nothing to warm up is not the same as never having started, and the row has to agree with the guard about whether the module is serving. A module whose `onBoot` threw gets **no** `onShutdown` — it is part-way through a warm-up it never finished, and handing it a half-built world to tear down is worse than not closing cleanly. `onBoot` receives the same frozen `ctx` object `register()` was given, not a second one built to look like it. **What a boot does to `installed_modules`** (`MODULE_SYSTEM.md` §2.4). The dispatch is the second half of a reconcile, and the order of its four steps is the design: 1. Clear the last boot's outcomes, so what is on display afterwards is what *this* boot did. `disabled` rows are left alone — that is an operator decision, not an outcome. 2. Write a row for every module found on the volume, with null provenance if it has none. A directory placed on the volume by hand is a supported install (§2.5 of the design of record) and without a row it could be neither disabled nor reported. 3. Mark any row whose directory is **not** on the volume `startup_failed` (stage `require`). Step 1 has just reset it to `enabled`, and a row claiming to be enabled for a module that is not there is the one state that is simply untrue. A plain uninstall leaves `disabled`, which step 1 never touches, so this catches only a directory deleted by hand. 4. Write down the outcome each module already carries — disabled by the operator, or failed during load or schema replay, both of which happen before the database is reachable — and only then dispatch `onBoot`. **The operator's switch wins over everything, including a failure.** A module whose row says `disabled` is guarded (§4.5), is not booted, and does **not** have its failure re-recorded: overwriting a deliberate `disabled` with an outcome would silently switch it back on at the next boot. **A bookkeeping failure is not a boot failure.** Every database write in the reconcile is individually caught. A row that will not update is bad — the admin panel shows the wrong thing — but it is strictly less bad than a site that will not start, and it must not stop the modules behind it from booting. Dispatch and reconcile live in `server/src/modules/lifecycle.js`, not in the loader: `routeManifest.js` and `swagger.js` both require `app.js` against a dead pool (§4.1), so the loader may not reach the database. The two halves meet at exactly one place — `loader.setState()` — so the in-memory record the dispatch guard reads and the row the admin panel reads are moved together and cannot disagree. ### 2.6 Schema fragments `schema` is an idempotent `.sql` file replayed immediately after core's own `schema.sql`, statement by statement, split the same way. It is subject to the same rules core's file already follows: `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, no `--` inside a string literal, no `DROP`. "Split the same way" is shared code, not a shared description: `utils/sqlStatements.js` holds the splitter and both callers use it. It is its own file rather than an export of `utils/db.js` because the loader validates fragments at require time and must not pull the mariadb pool into `app.js`'s require chain to do it. **The rules above are enforced at LOAD time, not at replay time** (PR 3). Everything §2.6 states about the SQL is knowable by reading the file, so a fragment that breaks a rule costs the module its mount entirely (§4.4's left-hand column) rather than mounting and then 503ing with tables half created. What is left for the replay is the class of failure only the database can report — an unknown column type, a bad foreign key — and those are post-mount and answer 503. **The check is a leading-verb allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Those are the four core's own `schema.sql` uses. It is an allowlist rather than the `DROP` denylist this section words it as because a fragment is **replayed on every boot**: `TRUNCATE` and `DELETE` would empty a table at every restart, `RENAME` would fail at the second one, and `GRANT`/`SET`/`USE` are core's business. A denylist only ever bans what somebody thought of. It is a leading-verb check and claims no more: `ALTER TABLE x DROP COLUMN y` passes it, and catching that needs a SQL parser — a large dependency for a rule whose job is stopping the obvious foot-gun early. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on the same grounds: it succeeds exactly once and fails every boot after, presenting to an operator as a module that broke on restart. **The replay is outside `ensureSchema()`'s retry loop.** Core's schema is retried ten times while the database comes up; a fragment that throws is one module's failure, not a signal the database is not ready, and retrying core's whole schema over one module's bad SQL would turn a 503'd module into a two-minute boot. Partial application is accepted rather than compensated for — MariaDB self-commits each DDL statement, so no transaction could roll back the tables created before the failing one, and the idempotence rule is what makes re-running a corrected fragment safe. **One caller replays nothing, deliberately.** `db/seed.js` (`npm run seed`) calls `ensureSchema()` standalone without requiring `app.js`, so no scan has happened and `fragments()`'s §7.6 throw would break seeding outright. The replay asks `isLoaded()` and logs the skip. That is the only sanctioned use of that predicate: everywhere else, reading the module list before `load()` still throws, because a booting server quietly getting no module tables is precisely what §7.6 exists to prevent. **And the server replays them itself, because it now scans the volume LATER than it ensures the schema.** `ensureSchema()` carries the replay for every ordinary caller, but `server.js` passes `replayModules: false` and calls `replayFragments()` of its own accord after requiring `app.js`. The reason is `MODULES` (`MODULE_SYSTEM.md` §2.7.2 decision 4): resolving a declared module set has to happen before the scan, it needs the host-allowlist setting to do it, and that setting does not exist until the schema and the seed have run — so core's schema now precedes the scan, and a replay wired to core's schema would run when there was nothing yet to replay. Nothing about the contract moves: fragments still run after core's tables exist and before any `onBoot`. **This was a live defect for the length of one afternoon**, and the shape of it is worth keeping: it announced itself only as the skip line above, appearing in a *booting server's* log where it means the opposite of what it means in `npm run seed`, and on a database whose tables already existed the module started perfectly. **Table names are namespaced and collision-checked.** New tables must be prefixed `_`. The loader extracts every `CREATE TABLE IF NOT EXISTS ` from the fragment and rejects the module if a name collides with a core table or with another module's — a wrong `DROP`-free fragment can still silently adopt someone else's table otherwise. **module-uo is grandfathered.** Its 27 tables are named `shard_*` (26) and `uo_link_config` (1), and renaming them is a data migration this workstream explicitly does not do (`MODULE_SYSTEM.md` §1.6 puts the count at 25; the working tree says 27 — see §6.4). They are registered in the loader as an explicit legacy allowlist keyed to `id: "uo"`, so the prefix rule holds for every module written after this one. `purge` is the destructive counterpart, run **only** by the explicit admin purge action, never by uninstall. Required whenever `schema` is present: a module that can create tables and cannot drop them leaves an operator with orphaned data and no supported way to remove it. ### 2.7 What a module must not do - `require` anything outside its own directory except node built-ins and its own `dependencies`. - Mutate `ctx`, `req.user`, or any object core handed it. - Register an Express error handler, or any middleware at the app level. - Read `process.env` for core configuration. Its own config is a `settings` key or its own table. - Call `process.exit`, install signal handlers, or start a listener. - Write outside `ctx.paths.moduleRoot` and the upload directory. - **Open a connection to a game server from the website process** — a game socket, an RCON channel, a query port, an engine's admin API. A module talks to a **sidecar**, and the sidecar talks to the game. Added in 1.4.0. **Why the sidecar is not optional** (added 2026-08-12; the reasoning, and the worked example, are Integration Kit chapter 3 — [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.11): - **The website is the internet-facing process and the game is not.** A module dialling the game directly makes the public web app the thing the game trusts, and puts the game's address in the same process as every request from the internet. With a sidecar, the game dials **out** and opens no listening port at all, which is the invariant [`../link/PLAN.md`](../link/PLAN.md) exists to hold. - **The sidecar owns the durable copy.** It is a non-blocking dumb forwarder that **persists before it forwards** — `uo-link` writes every event and every board snapshot to SQLite (`store.rs`) and serves its REST reads from there. So a website that is down, restarting or mid-deploy loses nothing, and a page renders the last thing the game said instead of going blank. A module holding the connection itself has nowhere to put what arrives while the website is not running. - **Neither side can stall the other.** The game's plugin enqueues onto a bounded drop-oldest queue and a writer thread drains it; the live feed is best-effort and lossy on purpose (a lagging consumer drops frames) because durability is the store's job, not the socket's. A module that owns the socket inherits both problems inside an Express process, where the failure mode is a wedged request handler. This one is **normative prose with no CI behind it** — an outbound socket is not statically detectable the way an internal `require` is (§5.1), so it is enforced in review. Stated as a rule anyway, because the alternative is that every second module re-decides it, and the first one to decide wrong finds out during an outage. ### 2.8 The OpenAPI fragment Every module that registers routes ships `swagger-fragment.json` in its bundle root. Core merges the fragments of started modules into `/api/docs.json`; the full reasoning and the collision rules are §6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered route has no path in the fragment, and core wins every key collision. **Built in phase 3 slice 5** (module-uo#6, website#141). Four things settled while building it, all of which a second module inherits: - **The filename is fixed here, not declared in `module.json`.** `swagger-fragment.json` in the bundle root, like `module.json` itself — so a module cannot point core at some other file, and core's loader has one path to check. A module that ships none is simply absent from the merged document: whether it registered routes without documenting them is the *module's* CI to answer, where the routes are known. Core cannot tell a module with no routes from one that forgot. - **Namespace what you DEFINE; reference core's by core's name.** `UoShardStatus` is defined by the module; `#/components/schemas/Error` and `ValidationError` are referenced and **not** redefined. Both resolve in the merged document, which is the only place both halves exist — and shipping a copy of `Error` would be a collision core drops, arriving at the same result the expensive way. This is the practical form of "core wins": it makes the two cases feel different in the source, which is where the mistake would otherwise be invisible. - **Generate the fragment from the module's own registrations.** swagger-autogen needs a *file* and cannot follow `api.registerRoutes`, so the module's generator runs its own `register()` against a recording `api` and resolves each router back to its source through `require.cache`. A mount prefix then exists in exactly one place. The two values it cannot derive — the tier base paths and the slot's mount, both §2.4's — are checked against a real core by the §5.3 job rather than trusted. - **swagger-autogen reports a broken annotation and then succeeds.** It `console.error`s "Syntax error" or "out of structure", drops that annotation, and prints `Success`. Both repos' generators now capture those diagnostics and fail on them, which found six annotations documenting less than they claimed. Two ways one breaks: an object literal a brace short, and a `"` or backtick inside a single-quoted description (the tool re-quotes both to `'` before evaluating, ending the string early). A third, which nothing but a rendered page catches: an escaped apostrophe survives literally, because the annotation is not evaluated as JavaScript. ### 2.9 What core publishes about a module `GET /api/v1/public/modules` — anonymous, database-free, never site-mode gated. ```json { "modules": [ { "id": "uo", "name": "Ultima Online", "version": "1.0.0", "capabilities": ["shard", "atlas", "market"] } ] } ``` Four fields, in the loader's scan order (§4.2). What is *not* there is the design: - **Only `started` modules appear.** The endpoint answers what this backend is serving. A module that is `disabled` or `startup_failed` is **absent**, which is the same answer §4.4 already gives for its routes and its nav — a client renders a site without that capability rather than one advertising a capability that 503s. `installed` and `registered` are likewise absent: neither is serving yet. - **No `state`, no `failure_stage`, no `failure_reason`.** Where a module broke and how far it got is operator detail for the admin Modules screen. An anonymous visitor is not told that something is broken, and the message — which is an exception string from inside core — never leaves the server. - **No `client` chunk URL.** `utils/htmlShell.js` injects a `