# 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.8.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.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.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([...])` and `api.registerAudiences([...])` (§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.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. **`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 `