# The Module System — design of record

**Status: SHIPPED.** All six phases are built and the `edge` → `main` cutover merged on
**2026-08-12** ([website#150](https://gitea.whitlocktech.com/RunicGateway/website/pulls/150)), so
core on `main` is game-agnostic: **166 public + 2 internal** routes with no game in them, and the 72
UO routes arriving from [`RunicGateway/Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
The contract is at `MODULE_API_VERSION` **1.5.0**. §2.7 tracks what each phase settled, §2.9 records
the cutover itself.

This document stays in the present tense about the *design* and the past tense about the *build*.
Where it says "will", read it as the plan speaking; the "as landed" notes under each phase are what
actually happened, and several of them record the plan being wrong. Every decision in Part 3 was
settled with the org lead; Part 1 records what was verified against the working trees on 2026-08-10,
including the places the original draft was wrong.

**The normative contract is [`MODULE_API.md`](MODULE_API.md)** (Phase 1). This document decides what
the module system *is*; that one decides exactly what a module may call. Where the two differ, that
one wins — its Part 6 lists every place it amends this document.

**Goal.** Turn Runic Gateway from a UO/ServUO-specific platform into a game-agnostic one. The core
architecture is unchanged — sidecar → website → browser. What changes is that game-specific
behaviour (routes, tables, screens, nav) leaves the core website and becomes an installable
**module**. An operator installs the base site, installs the module for their game, and restarts.

**The model is WordPress plugins, not a build system.** An operator never compiles anything to
deploy a module. The module's own CI publishes it prebuilt; the operator drops it in and enables it.
This single constraint drives most of Part 2.

**Out of scope.** The sidecar's per-game protocol adapters. `link/`, `servuo-plugins/` and
`installer/` are the shard side and stay independent of this work — the installer runs on the shard
host and by design never contacts the website (`installer/src/cli.rs:162`). Also out of scope: the
Android app, which gets its own plan covering module discovery and multi-server profiles; this plan
only owes it the capability endpoint in §2.5.

---

## Part 1 — What is actually there

### 1.1 The parts that are already clean

The extraction is closer to a folder move than a teardown, and that is not an assumption:

- **Models.** `server/src/model/` holds 31 directories; exactly 8 are UO — `shardAtlas/`,
  `shardClilocs/`, `shardEvents/`, `shardLinks/`, `shardMarket/`, `shardState/`, `shardVisibility/`,
  `uoLinkConfig/`. No mixing with `users/`, `posts/`, `pages/`, `wiki/`, `settings/`.
- **Routers.** All 13 UO router/controller files are single-purpose, with no shared code:
  `admin/shard.router.js`, `admin/shardAtlas|shardClilocs|shardOps|shardVisibility.controller.js`,
  `admin/uoLink.router.js` + `.controller.js`, `public/atlas.router.js` + `.controller.js`,
  `public/shard.router.js` + `.controller.js`, `player/shard.router.js` + `.controller.js`.
- **Mount points.** `router/v1/{public,admin,player}/index.js` are pure mount tables that declare no
  routes of their own. Module mounting drops straight in with no restructuring.
- **The API surface is small and knowable.** The nine UO `utils/` files import only four things from
  core: `settings.model`, `logger`, `auth`, `pushDispatch`. That is the empirical basis for §2.1 —
  the contract is derived from what the real code uses, not designed speculatively.

### 1.2 Route prefixes: one flat module prefix is impossible

A module cannot be handed a single pre-scoped router at, say, `/api/v1/game/uo`, because the existing
UO URLs live under three different access tiers — `/api/v1/public/shard/*`,
`/api/v1/admin/shard/*`, `/api/v1/player/shard/*` — and those URLs are protected by
`routeManifest.test.js` and consumed by three shipped clients (SPA, Android app, Discord bot).

**Resolved:** a module owns a *named slot inside each tier*. It still only ever holds a pre-scoped
`express.Router()` and structurally cannot reach above its mount point; it simply holds one per tier.

```json
"mounts": {
  "public": ["/shard", "/atlas"],
  "admin":  ["/shard", "/uo-link"],
  "player": ["/shard"]
}
```

The loader rejects a prefix collision between two modules, or between a module and core, at
registration time. That check is unavoidable; the per-request routing boundary is not left to the
module's good behaviour.

### 1.3 There is no server-side nav list, and there must not be one

`server/src/utils/navOverrides.js` (lines 13–21) refuses this explicitly:

> **What this module cannot check, deliberately: whether a `to` exists.** The three base NAV arrays
> are client constants (SiteHeader.jsx, AdminLayout.jsx, PlayerPortalLayout.jsx). Shipping a copy of
> them to the server would create a second source of truth for navigation that drifts the first time
> a route is added…

Confirmed: `export const NAV` lives in `client/src/components/SiteHeader.jsx:23`,
`client/src/routes/admin/AdminLayout.jsx:56`, `client/src/routes/player/PlayerPortalLayout.jsx:41`.
The server validates override *shape* and nothing else.

**Resolved:** nav registration is **client-side**, performed by the module's own client bundle
against a core-provided registry. No server nav API is introduced, and
`THEMING_AND_NAV.md`'s override model is untouched. The resulting pipeline is:

> registered defaults (core + modules) → role/feature filtering → admin overrides → rendered nav

### 1.4 Module nav items interleave into *core* groups

Appending a "UO" group is not enough. Today's UO items sit inside core groups in `AdminLayout.jsx`:
group **Moderation** holds `/admin/shard-ops` and `/admin/houses`; group **System** holds
`/admin/shard`, `/admin/shard-visibility`, `/admin/shard-atlas`; the unnamed footer group holds
`/admin/characters`. `MOD_PATHS` (line 109) additionally hardcodes two UO paths as
moderator-visible.

**Resolved:** nav registration takes a target group and order (`{ group: 'Moderation', order: 30 }`),
and `MOD_PATHS` becomes a `roles`-derived computation rather than a path allowlist.

Built in Phase 2 PR 8. Two things it turned up that this section did not predict. The interleave has
to happen **before** the admin-override merge and not after it, because that merge drops any `to`
its base array does not declare — appending module rows afterwards would leave them uneditable in
Admin → Navigation, which today's UO rows are not. And there was a **third** hardcoded list: the
redirect that confines a moderator checked three path prefixes, while `MOD_PATHS` listed five paths,
and they disagreed about `/admin/houses` — a moderator who clicked Houses in their own sidebar was
bounced straight back to Moderation. One derivation cannot disagree with itself.

### 1.5 The public nav's feature-gating mechanism is itself a shard system

Ten of the sixteen entries in `SiteHeader.jsx`'s NAV carry a `feature:` key (`status`, `champs`,
`guilds`, `governors`, `houses`, `ruleset`, `atlas`, `leaderboards`, `market`), resolved by
`useShardFeatures()` against `/api/v1/public/shard/features` — the shard visibility system.
Extracting the module removes the provider that core's own nav filter depends on.

**Resolved:** core keeps a generic feature-flag context with a **registerable provider**; the module
registers its `useShardFeatures` for its own namespace. No core nav item carries a `feature` today,
so with no module installed the filter is a correct no-op.

Built in Phase 2 PR 8, and core registers into it **now** rather than at extraction: `useShardFlags`
goes in under the owner id `core`, so the ten rows above are already resolved through the seam and
`SiteHeader` runs one mechanism instead of two. Which provider answers a row is decided by the
module that registered it, not by a prefix parsed out of the flag name, so those ten keep the exact
strings they carry today and Phase 3 moves them without a rename.

### 1.6 There is no migration system to model a module migration runner on

`server/db/schema.sql` is a single idempotent file — 1,380 lines, 67 tables — replayed in full on
every boot by `ensureSchema()` (`src/utils/db.js:48`), split on `;` and executed statement by
statement. Schema evolution uses `ALTER TABLE … ADD COLUMN IF NOT EXISTS` / `MODIFY COLUMN`
(from line 1322). There is **no version table, no runner, no migrations directory**.

A module-scoped migration runner would therefore be the *first* migration system in the codebase,
and would leave core and modules on two different schema models.

**Resolved:** modules ship a `schema.sql` fragment, replayed idempotently by the same
`ensureSchema()` immediately after core's. Forward-only falls out for free — it is all an idempotent
replay can be. Install and upgrade become the same operation. Uninstall stops the fragment being
replayed; **purge** is a separate, explicit, destructive admin action that runs the module's
`purge.sql`. A real migration runner, covering core *and* modules together, is a legitimate future
workstream; it is not a prerequisite for this one.

Twenty-seven of the tables move with the module: the 26 `shard_*` tables plus `uo_link_config`.
(This said 25 when written; the working tree was recounted in Phase 1 — see
[`MODULE_API.md`](MODULE_API.md) §6.4.)

### 1.7 Boot and shutdown is a lifecycle gap

`server/src/server.js` holds eight UO call sites that routes, nav and schema do not cover:

| Line | Call |
| --- | --- |
| 92 | `shardAtlas.refreshOnBoot()` |
| 99 | `shardClilocs.refreshOnBoot()` |
| 107 | `shardMarket.refreshDisplayNames()` (conditional on the cliloc import result) |
| 130 | `uoLinkSocket.start()` |
| 131 | `checkUoLink()` — plus the whole function at 147–169 |
| 179 | `uoLinkSocket.stop()` |
| 180 | `shardBroadcast.closeAll()` |
| 7–19 | five top-level `require`s of UO modules |

**Resolved:** the API surface includes `onBoot(ctx)` and `onShutdown()`, each individually
try/caught by the loader per §2.4.

### 1.8 Three core files are genuinely entangled

Everything else is a folder move. These are not:

1. **`src/config/notificationStreams.js`** — the push stream catalog. `mapShardEvent()` and most of
   `STREAMS` are shard-derived, and it imports `PUBLIC_KINDS` from `utils/shardBroadcast`. Push
   *infrastructure* is core; this *catalog* is module content.
   → `registerNotificationStreams(streams)`.
2. **`src/utils/pushDispatch.js`** — core infrastructure, but `fromShardEvent()` (line 112) requires
   the `shardLinks` model (line 21) and `mapShardEvent` (line 23).
   → invert: `publish()` stays core, `fromShardEvent` moves into the module and calls it.
3. **`src/utils/announceWorker.js`** — the news dispatcher, with two delivery legs: Discord (core)
   and town crier (module, via `uoLinkClient.postTownCrier`, line 36).
   → `registerAnnounceLeg({ leg, label, dispatch, classify })`.

`src/utils/newsGump.js` is module-side (news → in-game gump) and moves whole.

**Done in Phase 2 PR 4**, with core still the only registrant — the registries are
`src/modules/registries.js` and core goes through them by the same door a module will
(`registerCore()`, called explicitly from `app.js` before `modules.load()`). What each of the three
became:

1. Split in two. `config/coreStreams.js` is core's one stream (`news.post`, produced by the website's
   own posts path); `config/shardStreams.js` is the other seven plus `mapShardEvent` and the
   public-safety filter, and moves to module-uo whole. `registerNotificationStreams` lost its
   `mapEvent` half — see [`MODULE_API.md`](MODULE_API.md) §2.4 for why that was a leftover, and what
   follows for the public/personal split.
2. Inverted. `pushDispatch.js` is `publish` + `isAllowedEndpoint` and nothing else;
   `utils/shardPush.js` holds `fromShardEvent` and is what `shardIngest` now calls.
3. Legs became registrations, and per-leg **rows**. The `towncrier_*` / `discord_*` column groups on
   `announce_jobs` could never have held a module's leg — a module cannot `ALTER` a core table — so
   they became `announce_job_legs`, backfilled and dropped in the same idempotent replay. The worker
   no longer contains the word "towncrier": it iterates whatever is registered.

The residue in core is a one-time backfill block in `schema.sql`, deletable once every deployment has
booted it, and the two lines of `registerCore()` that Phase 3 turns into module-uo's `register()`.

### 1.9 A fourth mount shape: module routes under a core resource

`router/v1/admin/users.router.js` mounts `usersShard.controller.js` at six UO sub-paths of a **core**
resource — `/:id/shard/accounts|sales|houses|online|standing` and `DELETE /:id/shard/link/:account`.
And `GET /api/v1/admin/users/:id` (line 159) is itself served by `usersShard.getUser`, which is core
semantics that ended up in the UO controller by proximity.

**Resolved, two parts:** (a) `getUser` moves back into `admin.controller.js`; (b) core declares a
narrow **extension slot** on `/admin/users/:id` that the module mounts into, so core never learns
what "shard" means and all six URLs are preserved. Only core may declare an extension slot; a module
may not invent one.

**Both done in Phase 2 PR 4**, with core filling its own slot: the six paths are
`router/v1/admin/usersShard.router.js`, registered into `admin.users.detail` by `registerCore()`, and
Phase 3 changes the registrant rather than the routes. The slot's router is created at declare time
and filled later, because `users.router.js` is required while `app.js` is still being built. It is
mounted **last** on the resource, so core wins any path conflict by first-match.

One consequence was not foreseen and is worth the warning: **a slot is invisible to static analysis.**
There is no literal mount for `swagger-autogen` to follow, so the move silently deleted all six paths
from `swagger-output.json` while printing `Success`. The OpenAPI build now merges a generated
fragment per filled slot — [`MODULE_API.md`](MODULE_API.md) §6.6.

### 1.10 The Discord bot has no UO logic

The draft listed the bot's "UO-specific event/moderation logic" as an extraction candidate. Grepping
`website/bot/src` for `uo|ultima|shard|towncrier|governor|vendor` returns **zero matches**. The bot's
only site coupling is `src/site/siteApiClient.js`. There is nothing to extract.

### 1.11 The installer is not, and will not become, the delivery path

Two independent reasons, and the decision is that website and installer stay independent:

1. **The installer runs on the shard host and never contacts the website** — `src/cli.rs:162`: *"The
   installer never contacts your website, never deletes anything from your…"*. A website module is a
   website-host artifact.
2. **`Bundle` is hardcoded to exactly two components.** `installer/src/bundle.rs` declares
   `pub link: LinkComponent` and `pub overlay: OverlayComponent`, both non-`Option`, alongside a
   single top-level `protocol: u32` and `SUPPORTED_SCHEMA: u32 = 1`. A third artifact type would be
   a schema-2 bump — and the sidecar/overlay protocol number has nothing to say about a website
   module anyway.

Note also that **there is no SHA256SUMS trust anchor** anywhere in the installer, contrary to the
draft. The real model is a per-asset `sha256` field inside a bundle JSON fetched anonymously over
HTTPS from the `bundles` branch. No signatures. The *shape* is worth reusing; the name was wrong.

### 1.12 Modules must mount synchronously, from the filesystem

`server/scripts/routeManifest.js:38` and `server/swagger/swagger.js:29` both walk the Express stack by
`require`-ing `src/app.js` **with no database connection** — the manifest script deliberately points
the pool at a dead port. A DB-driven async loader would make module routes invisible to both,
silently breaking the frozen-URL-surface test and shipping undocumented routes.

**Resolved:** the **filesystem is the mounting source of truth.** `app.js` synchronously scans
`modules/*/module.json` at require time and mounts what it finds. The `installed_modules` row carries
state and metadata (version, installed-at, `startup_failed` reason, admin enable/disable) and is
reconciled against the filesystem once the DB is up. A module disabled in the DB is skipped by a
one-line dispatch guard rather than being unmounted, so the URL surface stays deterministic and
generatable.

### 1.13 The client seam is a route registry, not an admin-panel loader

`client/src/App.jsx` is a flat 235-line static route table, and UO routes appear in all three areas —
public (`/site/shard`, `/site/shard/activity`, `/site/governors`, `/site/houses`, `/site/atlas`,
`/site/atlas/:slug`, `/site/market`, `/site/market/vendors/:serial`, plus champs, guilds, rules,
leaderboards), admin (`shard`, `shard-visibility`, `shard-atlas`, `shard-ops`, `houses`,
`characters`, `characters/:serial`) and player. Nav is one consumer of that registry, not the
mechanism itself.

### 1.14 Production is a prebuilt, pull-only image — and that is the binding constraint

`website/Dockerfile` bakes `client/dist` at image build time, and `docker-compose.yml` has no
`build:` stanza at all (deliberately: *"a production host can only ever pull, never accidentally
build"*). Combined with the requirement that **an operator must never build anything to deploy a
module**, this rules out build-time inclusion of module client code, which the draft had as its
default.

It also rules out import maps as the shared-dependency mechanism: `config/csp.js:49` sets
`'script-src': ["'self'"]` with no `'unsafe-inline'`, and an import map must be an inline
`<script type="importmap">`.

**Resolved** — see §2.6. The path that survives all three constraints is: the module's CI ships a
**prebuilt ESM chunk**, core hands it React through a **global** rather than an import map, and
`htmlShell.js` injects a **same-origin** `<script type="module" src>`, which `'self'` already
allows. Verified in a browser against the enforced policy in Phase 2 PR 7, not only reasoned about
([`MODULE_API.md`](MODULE_API.md) §7.7).

---

## Part 2 — The plan

### 2.0 Scope and non-goals

Out of scope unless Phase 1 turns up a concrete reason otherwise: hot module reload; sandboxing
beyond the boundary stated in §2.2; inter-module dependency resolution; a module marketplace or
discovery UI; automatic data rollback beyond the forward-only model in §1.6. Install and uninstall
require a controlled **restart** — never a rebuild.

Added: **no installer changes at all** (§1.11), and **no Android changes in this workstream** beyond
the one consequence recorded in §2.8.

### 2.1 The API surface, derived from real dependencies

Taken from what the UO code actually imports today. Nothing speculative — if module-uo does not use
it, it is not on the list.

**Server — the `ctx` handed to a module's entry point**

| Member | Backed by | Why it is here |
| --- | --- | --- |
| `ctx.db` | `utils/db` (`query`, `pool`) | every `*.db.js` |
| `ctx.settings` | `model/settings/settings.model` | `shardIngest.js:20` |
| `ctx.log(namespace)` | `utils/logger` | all nine UO utils |
| `ctx.auth` | `utils/auth` | `shardVisibility.js:26` |
| `ctx.push.publish()` | `utils/pushDispatch` | `shardIngest.js:22` |
| `ctx.secretBox` | `utils/secretBox` | `uoLinkConfig` model |
| `ctx.middleware` | `requireAuth`, `requireRole`, `siteMode`, `validate` | every UO router |
| `ctx.uploads` | `admin/imageUpload.js` | atlas art import |
| `ctx.posts` | `model/posts/posts.model` | `newsGump.js`, announce legs |

**Server — what a module registers**

`registerRoutes(mounts)` (§1.2) · `registerExtension(slot, router)` (§1.9) ·
`registerNotificationStreams({ streams, mapEvent })` (§1.8) ·
`registerAnnounceLeg({ leg, dispatch, classify })` (§1.8) · `onBoot(ctx)` / `onShutdown()` (§1.7).

**Client — what a module registers**

`registerRoutes({ public, admin, player })` (§1.13) ·
`registerNav({ nav, group, order, feature })` (§1.3, §1.4) ·
`registerFeatureProvider(namespace, hook)` (§1.5).

**The acceptance test for the whole contract:** `module-uo` runs with **zero** `require`/`import`
reaching outside its own directory. Any gap extends the surface *before* extraction proceeds.

### 2.2 What the module boundary is, and is not

A module runs in the same Node process with full access. The boundary is a **code-organisation and
distribution boundary, not a security boundary** — which is fine for a self-hosted operator
installing software they chose, the same trust category as running its schema fragment. What makes
it worth having is that modules interact with core through a *defined* surface, so a core refactor
cannot silently break a module. Hence the zero-internal-imports rule above, enforced in CI rather
than by review.

### 2.3 Module packaging — one repo, one bundle

**`RunicGateway/Module-uo`** — `https://gitea.whitlocktech.com/RunicGateway/Module-uo.git`, note the
capital `M`, matching `Android-app`'s casing rather than the lowercase directory name. The repo
exists but is **empty** as of 2026-08-10: no branches, no initial commit. Its first commit needs the
usual scaffolding — `README.md`, `LICENSE.md` (GPL-3.0-or-later), `CONTRIBUTING.md` with the
AI-disclosure clause, the PR template, and CI.

Server and client halves live side by side and version together, so a route and the screen that
calls it can never be mismatched:

```
RunicGateway/Module-uo
  module.json          id, version, coreApi range, mounts, extensions
  server/              routers, controllers, models, utils
  server/db/schema.sql fragment replayed by ensureSchema()
  server/db/purge.sql  destructive, only ever run by an explicit purge
  client/src/          route components, nav registrations, feature provider
  client/dist/         PREBUILT ESM chunk, published by module CI
```

Release artifact: `module-uo-<version>.tar.gz` plus a manifest carrying its `sha256`.

The module's **id** is `uo` — that is what appears in `module.json`, in `installed_modules`, in the
`modules/<id>/` path and in the URL segment. `Module-uo` is the repository; `module-uo` elsewhere in
this document names the module and its artifact, not the repo.

`module.json` declares a `coreApi` semver range, checked at boot against a `MODULE_API_VERSION`
constant in core; a mismatch fails **loudly** rather than silently. This is a separate number from
`PROTOCOL_VERSION`, which versions the shard wire and says nothing about a website module.

### 2.4 The module state machine

`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable states.

**A module that fails to load must never take the site down.** The loader catches failures across the
module's entire lifecycle — require, schema fragment, router construction, registration calls,
`onBoot` — not merely those that surface after a router object was returned. Any failure at any point
marks that one module `startup_failed`, records the reason, and the site comes up with that module's
routes and nav absent. `startup_failed` is recoverable from the admin panel — disable, retry, or roll
back to the previous version — with no shell access to the box.

**Where the states live.** One `installed_modules` row per module, keyed by its id, with the machine
held in a single `state` column carrying all five values — the shape this section already describes,
rather than a policy flag beside a runtime one. The table also carries `name`/`version` for the admin
screen, `failure_stage` + `failure_reason` for [`MODULE_API.md`](MODULE_API.md) §4.4's recorded
reason, `source` + `sha256` for the
install provenance of §2.5 below (both null for a directory placed on the volume by hand, which stays
supported), and `installed_at` / `started_at` / `updated_at`. Full column list in
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §3.

**The row is a record of what happened, never the source of truth for what is mounted.** The loader
scans the filesystem at require time, before the database is reachable (API §4.1), so the URL surface
is a property of the volume and not of a row here. What the row decides is whether a mounted module
*answers* (`disabled` ⇒ its guard 404s, API §4.5) and what the admin panel shows after a failure.
This is also why
`routes.manifest.json` can be generated against a dead database.

*(Amended in Phase 4, §2.7.2 decision 3: **disable is not only a guard flip.** It dispatches that one
module's `onShutdown` first, so a module an admin switches off actually stops — releases its sockets,
closes its streams — rather than merely becoming unreachable while it keeps working in the
background. Enable is deliberately not the mirror image: there is no `onBoot` re-dispatch, so
enabling flips the row and the admin screen offers a restart. The claim this section makes is
unaffected — the row still decides only whether a mounted module answers, never what is mounted.)*

**`disabled` is the only state a boot leaves alone.** Every boot resets each non-disabled row to
`enabled`, clearing any recorded failure, and the load that follows writes this boot's outcome —
`started` or `startup_failed`. Three consequences, all deliberate:

- **A `startup_failed` module is retried on every restart.** An operator who fixes the underlying
  cause — a truncated file, a missing dependency, a database that was not up yet — gets the module
  back by restarting, with no admin-panel visit. The cost is that a deterministically broken module
  re-records its failure each boot, which is the honest thing for it to do.
- **A stale reason can never be shown against a running module**, because every non-failing
  transition clears the failure columns.
- **Disabling is an operator decision, not an outcome**, so it survives restarts untouched — and a
  module the operator switched off is neither started nor re-recorded as failed if it happens to be
  broken. `installed` is likewise transient: it is the gap between an install writing the row and the
  restart that resolves it.

A re-install or an upgrade refreshes `name`/`version`/provenance and deliberately leaves `state`
alone: upgrading an enabled module must not silently switch it off, and re-installing a disabled one
must not silently switch it on.

**A row whose directory is gone is marked `startup_failed`** (stage `require`, reason "module
directory not present on the volume"), settled with PR 5. The boot reset above has just moved it to
`enabled`, and a row claiming to be enabled for a module that is not on the volume is the one state
that is simply untrue — it would be read that way by the admin panel and by
`GET /api/v1/public/modules` alike. This catches only a directory deleted by hand: an uninstall
leaves the row `disabled`, which the reset never touches.

### 2.5 Install, uninstall, purge

Modules live on a **mounted volume**, not in the image. That is what makes the WordPress model work
against a pull-only image. *(Settled in Phase 2 PR 9: it is a **bind mount** of `./modules`, not the
named volume this sentence originally reached for by analogy with `uploads` — hand-placing a module
directory is a supported install below, and a named volume would route it through `docker cp`. The
image's copy is excluded by `.dockerignore`, so a module in a builder's working tree can never ship
inside an image; see `modules/README.md` in the website repo.)*

**Install:** admin selects the module → bundle downloaded from the module repo's release and verified
against its `sha256` → unpacked into `modules/<id>/` on the volume → `installed_modules` row written →
**restart**. On boot the loader scans the filesystem (§1.12), validates prefixes, mounts, replays the
schema fragment, runs `onBoot`, and each module reaches `started` or `startup_failed`.

*(Made concrete in Phase 4, §2.7.2 decisions 1 and 2: "admin selects the module" is **pasting the URL
of a release's install manifest** — there is no catalog, because a catalog would make core's release
cadence decide which modules exist. And the **restart is a button on the same screen**, not an
instruction to go and restart the container: it runs `moduleLifecycle.shutdown()` and exits, and the
supervisor the shipped compose file already declares brings the process back.)*

Nothing is compiled at any point. The operator restarts; they never build.

**Uninstall** (default, non-destructive): row set to `disabled`, directory removed, restart. The
module's tables and data are **retained**. **Purge** is a separate, explicit, destructive action that
runs `purge.sql`; it is never bundled into uninstall.

*(Amended in Phase 4, §2.7.2 decision 5 — and this one is a correction, not an elaboration.
`purge.sql` lives **inside the directory uninstall deletes**, so "purge afterwards" was never
possible: it would have left a `disabled` row whose Purge button had nothing to run. Purge is
therefore offered in **two** places, both while the file is still on disk — as a standalone action on
an installed module, exactly as this paragraph describes, and as an opt-in checkbox in the uninstall
dialog. It remains explicit and never implied; what changes is that "never bundled into uninstall"
becomes "never *implied* by uninstall". The cost is accepted and stated: an operator who uninstalls
without ticking the box keeps the tables, and getting rid of them later means reinstalling the module
first.)*

**Surfaces:** the admin panel, and the Docker environment under `website/` — a declarative module set
resolved at container start from the mounted volume, so a compose-managed host is not driven by
clicking. Both paths write the same `installed_modules` row and neither requires a build step.

*(Built in Phase 4 slice 3. The variable is `MODULES`, each entry `<id>@<version>=<install manifest
URL>`; resolution runs inside the server process, before the volume is scanned, and a module already
unpacked at the declared version is a no-op that makes no network call at all. "Both paths write the
same row" held exactly — including its provenance columns, which is why resolution runs where it can
reach the database. What §2.5 did not anticipate is that the two surfaces need a rule about who wins:
the declaration owns what is **on the volume**, the row owns whether a module **runs**. Uninstalling
a declared module from the panel therefore returns its files at the next start and leaves it
disabled.)*

### 2.6 How the client half loads

This is the piece §1.14 constrains hardest. Three requirements had to hold at once: the operator
builds nothing, production pulls a prebuilt image, and `script-src 'self'` forbids inline script.

1. **The module's CI builds its client half** with Vite in library mode, declaring `react`,
   `react-dom` and `react-router-dom` as **externals**. The module never bundles its own React —
   there is exactly one React instance, owned by core.
2. **Core exposes the shared dependencies on a global** before mount — `window.__rg = { react,
   reactDom, router, registry }` — and the module's externals resolve to it. A global, not an import
   map, precisely because an import map must be inline and CSP forbids that.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites the shell it serves, so
   this is an extension of a working mechanism, not a new one. The tag is
   `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
   nonce and no inline. *(Amended in Phase 2 PR 7: the tag is injected before `</body>`, not at the
   `</head>` rewrite this step assumed. Module scripts execute in document order and core's bundle
   has to run first, so the injection must be after core's own script tag wherever a bundler chooses
   to put it — [`MODULE_API.md`](MODULE_API.md) §3.1, which also states the static mount's root, its
   state guard and its cache policy.)*
4. **The SPA reads `/api/v1/public/modules`** to feature-detect against what this backend is
   serving. Registration happens when the injected chunk executes and calls `window.__rg.registry` —
   it is not gated on this call. *(Amended by [`MODULE_API.md`](MODULE_API.md) §6.7: this step
   originally said the SPA reads the endpoint "to learn what to load", which step 3 above had
   already answered a different way. Nothing waits on an API round trip to start loading. The
   endpoint's shape is API §2.9.)*

Phase 1 prototypes exactly this before anything is committed to it (§2.7).

### 2.7 Phases

**Phase 0 — unblock CI and scaffold the repo.** Land the one-line `pr-checks.yml` trigger fix on
`website` `main` (§2.9), cut `edge` from `main`, and give `Module-uo` its initial commit (§2.3).
Nothing else can be trusted until the first of these is done.

**Phase 1 — API contract + spike (blocking).** Merge this document. Write the contract at
[`docs/website/MODULE_API.md`](MODULE_API.md) — **done**; the places it amends this document are
listed in its Part 6, one of which (OpenAPI generation, §6.1 there) needs a decision before Phase 2
starts. Then a throwaway spike on an unmerged branch moving **`/api/v1/public/atlas/*`** behind the
proposed surface — the smallest honest test: six routes, DB-backed, no sidecar, no SSE, one boot
hook. The spike must *also* prove the §2.6 chunk load end to end, since that is the highest-risk
decision in the plan. Exit criteria: no internal-file imports, `npm run routes:manifest` produces a
zero-line diff, and the chunk loads under the enforced CSP.

**Phase 1 is complete.** The spike ran on `website` branch `spike/module-atlas` (cut from `edge`,
never merged) and **met all three exit criteria** — see [`MODULE_API.md`](MODULE_API.md) Part 7. §2.6
survives intact: the prebuilt chunk loads and renders under `script-src 'self'` with zero violation
reports. The one thing it changed is that §2.6's one-React rule turns out to have a server-side twin
nobody had written down — a module cannot resolve core's `express` either, so core hands that over
too (API §7.2).

**Phase 2 — Core scaffolding, no behaviour change.** One PR each, in order:

1. `installed_modules` table + the §2.4 state machine.
2. `src/modules/loader.js` — synchronous filesystem scan, manifest validation, prefix-collision
   rejection, per-module try/catch across the whole load path, mounting into the tier routers.
3. `ensureSchema()` extended to replay module fragments after core's.
4. The three de-entanglement registries (§1.8), with core still the only registrant.
5. Boot/shutdown hook dispatch in `server.js`, likewise.
6. `GET /api/v1/public/modules` — ids, names, versions and capabilities of the modules currently
   **serving**, shaped like the existing branding/site-settings endpoints (anonymous, database-free,
   not site-mode gated). The SPA and the Android plan both feature-detect against it; it is not what
   loads a client chunk ([`MODULE_API.md`](MODULE_API.md) §2.9 and §6.7).
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, the chunk's static
   mount and the `htmlShell` script injection — empty registry, no visible change.
8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` mount.

Exit criterion: no **existing** URL moves and every existing test passes. If Phase 2 changes one URL,
it is wrong. PR 6 is the single deliberate exception in the phase and it *adds*: `routes.manifest.json`
gains exactly one line, `GET /api/v1/public/modules`, and nothing else in the file moves. Every other
PR in Phase 2 produces a zero-line diff.

**Progress: complete — all nine PRs landed.**

- **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
  settled in §2.4 above.
- **PR 2** — `server/src/modules/loader.js`: the filesystem scan, manifest validation, prefix and
  table-name collision rejection, per-module try/catch and the tier mount, behind the §4.5 dispatch
  guard. Two decisions landed with it, both recorded in [`MODULE_API.md`](MODULE_API.md): the load
  trigger is **one explicit `modules.load(tierRouters)` call in `app.js`**, never a lazy scan
  (API §7.6); and the "does core own this prefix" check **probes the live tier routers** rather than
  a hardcoded table, so it cannot drift when core adds a capability router (API §4.3). The three
  de-entanglement registries and the two lifecycle hooks throw `not available until phase 2 PR 4/5`
  rather than no-op — an accepting stub would let a module believe it had registered something.
  28 tests, all on the failure paths.
- **PR 3** — schema fragment replay. `ensureSchema()` replays each installed module's fragment after
  core's, with the statement splitter extracted to `utils/sqlStatements.js` so both are split by the
  same code. The decision that shaped it, recorded in [`MODULE_API.md`](MODULE_API.md) §2.6: the
  fragment is **validated at load time and executed later**, split on whether a database is needed to
  know the answer — a fragment breaking a stated rule never mounts, while a failure only the server
  could report (a bad column type) is post-mount and 503s. The rules are enforced as a **leading-verb
  allowlist** (`CREATE`, `ALTER`, `INSERT`, `UPDATE`) rather than the `DROP` denylist §2.6 words them
  as, because the file is replayed on **every boot**. Found while wiring it: `npm run seed` calls
  `ensureSchema()` without ever requiring `app.js`, so the replay has to tolerate an unscanned loader.

- **PR 4** — the three de-entanglement registries, `src/modules/registries.js`. Core's own streams,
  its Discord announce leg and its users-detail routes all go through them, so the seams are
  exercised on every boot before a module depends on them; §1.8 and §1.9 above record what each
  became. Four decisions landed with it, all recorded in [`MODULE_API.md`](MODULE_API.md): announce
  legs became a **child table** rather than waiting for Phase 3 (§2.4 — a module cannot alter a core
  table, so a registered leg had nowhere to live); **`mapEvent` dropped** from the stream registry
  (§2.4 — a leftover from before the push inversion was settled); **core registers through the same
  staging area a module uses**; and core's six shard sub-paths **moved behind the slot now** rather
  than in Phase 3.

  Registering is **validate-then-commit**: the loader stages a module's claims and the second pass
  commits them, so a module that throws halfway through `register()` — or fails a later validation
  step — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule.

  Two build tools needed teaching, both because a mechanism this PR introduced is one they had never
  seen. `scripts/routeManifest.js` could not decode a **parameterised mount**: its unwinder expected
  a group shape express does not emit, and the branch had never run. It threw rather than guessing,
  which is exactly what it is for. And `swagger-autogen` could not follow a route into an extension
  **slot**, deleting 407 lines while reporting success; the fix is the fragment merge core owed
  anyway ([`MODULE_API.md`](MODULE_API.md) §6.6).

There is still no module on the volume and no boot wiring, so this changes nothing an operator or a
client can see: **884 tests pass** and `routes.manifest.json` is unchanged at 229 routes. The two
lines of OpenAPI that do move are the retry endpoint's summary and its `leg`, which is no longer a
fixed enum because the leg set is whatever has been registered.

- **PR 5** — boot/shutdown dispatch and the `installed_modules` reconcile, `src/modules/lifecycle.js`.
  `api.onBoot`/`api.onShutdown` stop throwing, `server.js` gains one call on each side, and the
  §2.4 machine finally runs against real outcomes — which is what makes §4.5's `disabled` 404 leg
  reachable for the first time. Four decisions landed with it, all recorded in
  [`MODULE_API.md`](MODULE_API.md) §2.5 and §4.4: **the loader classifies its failures** by §4.3 step,
  so `failure_stage` says where a module broke instead of being a column nothing filled; **a row whose
  directory is gone is marked failed** rather than left claiming `enabled` (§2.4 above); **core's eight
  UO boot call sites stay in `server.js`** until Phase 3, because unlike a registered announce leg a
  boot call site already has somewhere to live and moving it now would be extraction done early in a
  phase whose exit criterion is that nothing changes; and **`onBoot` gets no timeout** — shutdown races
  a SIGKILL and boot does not, and a slow `onBoot` delaying the listener is the contract's promise to
  a module that must warm up before it serves.

  The dispatch lives outside the loader for the reason the schema replay does: the loader is required
  by `app.js` against a dead pool, and this half is database-first. They meet at one function,
  `loader.setState()`, so the in-memory record the dispatch guard reads and the row the admin panel
  reads cannot drift apart.

  Still nothing on the volume: **900 tests pass**, `routes.manifest.json` is unchanged at 229 routes
  and the OpenAPI spec regenerates byte-identical.

- **PR 6** — `GET /api/v1/public/modules`, the first module-system URL a client can see. Four
  decisions, all recorded in [`MODULE_API.md`](MODULE_API.md) §2.9: **`started` modules only**, so a
  disabled or failed module is absent exactly as its routes and nav already are, and no visitor is
  told that something is broken; **no `state`, `failure_stage` or `failure_reason`** on the public
  surface — those are the admin screen's, and the reason is an exception string from inside core;
  **no `client` chunk URL**, because `htmlShell` hands the browser the tag rather than a URL to fetch
  (which amends §2.6 step 4 above — see API §6.7); and **no `siteMode` gate and no database**, the
  same class as `/public/status` and `/public/version`, so a client can still feature-detect while
  the site is in maintenance.

  It is a **capability router of its own** rather than a fifth singleton in `site.router.js`, and that
  is the load-bearing part. The loader's prefix-collision probe reads the live tier stack and skips
  root-mounted layers, because a `use('/', …)` matches every path — so a route declared inside the
  root-mounted site router is invisible to it. Mounting `use('/modules', …)` is what makes "no module
  may ever claim `/modules`" a rule the loader enforces rather than a convention a reviewer has to
  remember.

  **910 tests pass** (+9, every one of them on the boundary: what must *not* appear). The route
  inventory goes 229 → 230 (228 public + 2 internal) and moves by exactly the one added route;
  `routes.guards.json` records it with an empty `gates` list, which is itself the assertion that the
  endpoint is ungated. The OpenAPI spec gains the operation and the `PublicModules`/`PublicModule`
  schemas. The published mirror [`api-route-inventory.json`](./api-route-inventory.json) is refreshed
  to match.

- **PR 7** — the client half's delivery: `client/src/modules/registry.js`, `window.__rg`
  (`modules/shared.js`), the chunk's static mount and the `htmlShell` injection, with `App.jsx`
  reading `routesFor` for all three areas. The registry is empty on a bare core, so nothing an
  operator can see changes. Four decisions, all recorded in [`MODULE_API.md`](MODULE_API.md) §3.1 and
  §3.4: **routes now, nav in PR 8** — PR 7 is "a module chunk loads and renders its page", PR 8 is
  "it appears in the nav", which keeps the nav interleave and its override merge in one reviewable
  change; **the script tag is injected before `</body>`**, not into `</head>`, so the ordering that
  the whole client contract rests on comes from document structure rather than from Vite's choice to
  hoist core's entry into `<head>`; **the static mount is rooted at the entry's directory, behind the
  module's state guard, with `no-cache`** — a mount rooted at the module root would publish server
  source, `module.json` and the schema fragment, so an entry in the module root is rejected outright;
  and **the UI kit ships its seven real members**, with `AdminPage` struck from the contract rather
  than invented in core to satisfy a table.

  The verification that mattered was **not a test**. Everything passed against a build that did not
  work in a browser: core mounted before any module chunk had evaluated, because `document.readyState`
  during a deferred script is `'interactive'`, not `'loading'`. A module's routes were missing from
  the first render and its URL redirected home — indistinguishable from a module that failed to load,
  and with nothing logged anywhere. It was found by loading a hand-written chunk in Chrome, and that
  smoke is now written down as part of the contract ([`MODULE_API.md`](MODULE_API.md) §7.7) because no
  test in this repo can see it. The same run confirmed the property §3.6 called the highest-risk
  detail in the plan: the chunk executes under the **enforced** `script-src 'self'`, resolving core's
  React and UI kit off the global, with zero CSP reports.

  **933 server tests** (+23) and **123 client tests** (+14) pass; `routes.manifest.json` is unchanged
  at 230 routes and the OpenAPI spec regenerates byte-identical — `/modules/<id>/` is a
  filesystem-conditional static mount, not API surface, for the same reason `/uploads` and `/brand`
  are not in the manifest.

- **PR 8** — the nav half PR 7 deferred, and the two seams §1.4 and §1.5 asked for: `withModuleNav`
  (`client/src/modules/nav.js`) interleaving module rows into core's three navs, `MOD_PATHS` and the
  moderator redirect replaced by a `roles`-derived computation in `client/src/lib/adminNav.js`, and
  the generic feature-provider seam (`modules/features.jsx` + `modules/featureGate.js`) that core
  registers its own `useShardFlags` into. Four decisions, all recorded in
  [`MODULE_API.md`](MODULE_API.md) §3.3.

  **The interleave happens before the admin-override merge**, which is the decision the rest follow
  from: the merge is keyed by `to` and drops any key its base array does not declare, so module rows
  appended after it would be unorderable, unrelabellable and unhideable — and today's UO rows are
  all three of those things, so appending would make the extraction a visible regression for every
  operator who has ever edited their nav. Doing it first means a module row is an ordinary row to
  everything downstream: nothing in `navOverrides.js`, `NavEditor.jsx` or the layouts knows a module
  exists. **Moderator visibility derives purely from `roles`**, which moves two rows the old
  allowlist withheld — Dashboard, whose `roles` had always named moderator, and My Characters, which
  is ungated self-service — both toward what the server already permitted. **A row's `feature` is
  resolved by the provider its own module registered**, so the namespace comes from the registration
  rather than from a parsed string prefix. And **core registers through the same seam**, under the
  owner id `core`, so `SiteHeader` holds one mechanism instead of two and Phase 3 is a deletion.

  The PR also fixed a defect that predates the module system: the moderator redirect was a **third**
  hardcoded list, and it disagreed with `MOD_PATHS` about `/admin/houses`, so a moderator who
  clicked Houses in their own sidebar was bounced back to Moderation. The derived allow-list is
  computed from the **base** nav rather than the merged one, so an override — which is presentation
  — cannot move that boundary in either direction.

  **933 server tests** (unchanged — this PR is client-only) and **160 client tests** (+37) pass;
  `routes.manifest.json` is unchanged at 230 routes and the OpenAPI spec regenerates byte-identical.
  The [§7.7](MODULE_API.md#77-the-client-half-has-to-be-verified-in-a-browser--the-timing-bug-no-test-could-see)
  browser smoke was re-run, since this is the seam that rule exists for: a throwaway module
  registering nav in all three areas and a provider granting one flag and withholding another. It
  confirmed, in Chrome with the console open, that the row lands inside core's Moderation group
  rather than in an appended block, that the withheld row does not render while the granted one
  does, that a moderator reaches both `/admin/houses` and the module's own admin page, and that an
  admin can relabel a module row in Admin → Navigation and have it persist and apply — the whole
  point of merging before the override layer. Zero CSP reports, zero console errors.

- **PR 9** — the mount itself, which closes the phase: `docker-compose.yml` gains `./modules` at
  `/app/modules`, the `Dockerfile` creates that directory node-owned, `.dockerignore` keeps any local
  one out of the image and `modules/README.md` documents the directory for whoever opens it.

  It is a **bind mount, not the named volume** §2.5 assumed by analogy with `uploads`. Placing a
  module directory by hand is a supported install, and a named volume makes that a `docker cp` into a
  running container — the one install path an operator without the admin panel has, routed through
  the least discoverable mechanism Docker offers. A bind mount makes it `tar -xf … -C ./modules`, and
  makes the installed set something an operator can *see*. §2.5 is amended above; nothing else about
  install, uninstall or purge changes.

  Two consequences worth stating, because both are silent failures rather than errors. The directory
  is **tracked** — via its README, the same shape `brand/` already uses — because Docker recreates a
  missing bind-mount source as `root:root`, and the container runs as uid 1000: delete `modules/` from
  a checkout and the next install fails on a permission error that names no cause. And the mount is
  **read-write**, since §2.5's install unpacks into it from inside the container; deferring that to
  Phase 4 would have bought nothing, as a mount-mode change is a redeploy either way.

  `.dockerignore` matters more than it looks. `COPY . .` would otherwise bake whatever module the
  builder had checked out into every image — and because Docker seeds a *fresh* named volume from
  the image's contents, that module could have appeared on a production deployment that never
  installed it. The exclusion is what makes "modules live on a mount, never in the image" true rather
  than merely intended.

  Verified against a **running container**, which is the only thing that can check any of the above —
  a compose file that parses proves nothing about ownership, and nothing about what the image
  contains. The image carries an empty, node-owned `/app/modules` despite a module sitting in the
  build context. A module on the mount loads, mounts, runs `onBoot` and reaches `started`;
  `/api/v1/public/modules` lists it; its chunk serves from the entry's directory alone, with the
  module's own server source and `module.json` both `404`. The [§7.7](MODULE_API.md#77-the-client-half-has-to-be-verified-in-a-browser--the-timing-bug-no-test-could-see)
  browser smoke was re-run against the containerised stack rather than a working tree: in Chrome, the
  page renders on first paint inside core's `PublicLayout`, drawing React and the UI kit off
  `window.__rg`, with its nav row interleaved into core's public nav — under the enforced
  `script-src 'self'`, with zero CSP reports and no console errors. Removing the directory by hand and
  restarting reconciles the row to `startup_failed`/`require` exactly as §2.4 says, and leaves core
  healthy with no script injected and `{"modules":[]}` published.

  **933 server tests** and **160 client tests** pass, both unchanged — this PR ships no application
  code. `routes.manifest.json` stays at 230 routes and the OpenAPI spec regenerates byte-identical.
  The one source change is a comment: `scripts/routeManifest.js` enumerated the filesystem-conditional
  mounts it excludes and had never been told about `/modules`. Its filter is an allowlist, so the
  behaviour was always right and only the explanation was stale.

**Phase 2 is complete.** Core can discover, validate, mount, migrate, boot, publish, serve and
navigate a module it does not contain, on a deployment that builds nothing — and it does all of that
while no existing URL has moved. The exit criterion held: `routes.manifest.json` went 229 → 230 across
the whole phase, and the one added line is PR 6's deliberate `GET /api/v1/public/modules`.

**Phase 3 — Extract `module-uo`. COMPLETE 2026-08-11**, in six slices; the record of each is in
§2.7.1 and all four acceptance criteria are met (the table at the end of slice 5). Moves out of
`website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus
`getUser`** (§1.9); the shard-derived half of `notificationStreams.js` and the town-crier leg of
`announceWorker.js`; and on the client, roughly twenty route components, their nav registrations and
`useShardFeatures`.

Acceptance, all four required:

1. **Zero UO identifiers in core** — no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
   `modules/`. Enforced by a CI grep test, not by review.
2. **Zero internal-file imports** from `module-uo` into core.
3. **`routes.manifest.json` API diff is zero lines**, except the deliberate `GET /admin/users/:id`
   ownership move, which changes no URL. After extraction the core manifest no longer contains UO
   routes — `module-uo` generates and freezes its own in its own repo.
4. **A written `module-rust` dry run** — manifest, mounts, nav entries, one notification stream — not
   implemented, to prove the contract generalises before more is built on it. It lands as
   `docs/modules/rust-dryrun.md`, where §2.10 already aggregates module documentation; Phase 5's
   Integration Kit links to it rather than copying it, per the kit's own never-re-specify rule
   (§2.11).

#### 2.7.1 Phase 3's shape — settled 2026-08-11

Measured against `edge` at the close of Phase 2, the surface is **72 server files / ~9,700 lines**,
**51 client files / ~3,700 lines**, and **32 of core's 82 server test files**. The counts in the
paragraph above were written in Phase 0 against a smaller tree and are superseded by the slice table
below.

**The finding that sets the order: the two halves are independent.** Because §1.2 preserves API URLs
exactly, core's client keeps calling `/api/v1/public/shard/status` after that route is served by the
module, and a module page calls the same URL while core still serves it. Nothing forces a feature's
server and client halves to move together, so the extraction is **server-first, then client**, sliced
by feature — which keeps each PR inside one layer and one review's worth of context.

**Merge order across the two repos: `module-uo` first, then `website`.** The loader's `ownedByCore`
probe means a module cannot *load* while core still owns its prefix — but `module-uo`'s own CI never
loads it into core, so its PR merges perfectly well beforehand. Taking that order means `edge` serves
the feature from core right up to the moment core drops it, and there is never a window where the
branch is missing a feature outright. The reverse order would break `edge` at every slice boundary
for the length of a review. Verification is unaffected either way: a slice is proved by running the
*pair* together locally — the module branch checked out into `website/modules/uo`, the deletion
branch checked out in `website/` — before either merges.

Each slice is one `module-uo` PR (adds), one `website` PR (deletes), and one `docs` PR:

| # | Slice | Moves |
| --- | --- | --- |
| 0 | **The bundle skeleton** | `module.json`, both `package.json`s, `server/index.js` registering nothing, the Vite library build + the four shared-dep shims, CI armed, the §5.1 zero-internal-imports check. `website` untouched. |
| 1 | **The whole server half** | 40 files / ~9,674 lines, 25 of core's 82 test files, 27 of its 68 tables — every UO model, util, router and controller, `config/shardStreams.js`, `scripts/importSpawnAtlas.js` and the art JSON. **One merge, five commits** (below). |
| 2 | **Client extension slots** | Core only, and the one slice that adds rather than moves: the client twin of `declareSlot`/`registerExtension` (API §3.7), the `site.footer.status` and `admin.users.detail` slots, and core filling both itself. `module-uo` untouched. |
| 3 | **The whole client half** | **35 files / 5,332 lines** (measured; the 51/~3,700 above was counted differently) — all twelve public pages (`Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`) under `/uo/*`, every admin and player view under `/admin/uo/*` and `/player/uo/*`, `PlayersOnline`, `VendorSales`, `CharacterStats`, `GameAccounts`, the `data/` and `lib/` UO leaves, the public nav rows, the feature provider, and all three slot fills. |
| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `Status`, `Wiki`, `SiteFooter`'s prose, `heroLayout`'s defaults, `brand.js`'s tagline + description, `db/seed.js`'s wiki copy, two user-visible `NavEditor` strings, the comments in `navOverrides.js`, and the 190 dead lines of `api/client.js`'s `shard`/`atlas` namespaces — plus the two settings rows core seeded for module-uo, and the §5.2 CI check that keeps all of it out. `README.md` deliberately deferred to slice 5. |
| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` — **plus the OpenAPI fragment on both sides**, which §2.8/§6.1a had settled and neither repo had built, and the `README.md` slice 4 deferred |

##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it

The table above used to run to ten slices, with the server half split five ways by feature. It does
not divide, and the reason is that two contract rules compose:

- **A mount prefix is claimed whole.** `ownedByCore` probes the live tier router and `registerRoutes`
  validates single-segment prefixes, so `/admin/shard` moves as one unit — and it is a single
  386-line router carrying 25 routes that span atlas, clilocs, shard-ops, visibility, market *and*
  account links.
- **A model cannot be shared across the boundary** (§5.1), so a model moves with the *last* route
  that consumes it.

Take the closure and every prefix is in it:

```
/public/atlas ──shardAtlas── /admin/shard ──shardState,shardEvents,shardMarket── /public/shard
                                  │                                                    │
                       shardClilocs,shardLinks                                    uoLinkConfig
                                  │                                                    │
                            /player/shard                                      /admin/uo-link
```

Landing any one of the old slices alone would either strand core importing `modules/uo/` — which is
precisely what acceptance criterion 2 forbids — or delete routes core is still serving.

**Giving the admin routes their own prefixes would divide it, and is rejected.** `/admin/atlas` and
`/admin/clilocs` alongside a slimmer `/admin/shard` would make the closure fall apart. It also
changes API URLs, which §1.2 promises not to do — and not hypothetically: the shipped Android app
calls `POST /api/v1/admin/shard/kick`, `/ban`, `/unban`, `/broadcast` and the three `/pages` routes
(`data/api/AdminApi.kt`). A prefix rename is a client break, and the API surface is frozen for
exactly this reason.

**So slice 1 is one PR per repo, structured as five commits** along the old slice lines, reviewable
one at a time while landing atomically: atlas + clilocs · the live shard · market · account links and
the `admin.users.detail` slot · the town-crier leg. The alternative considered was stacked PRs into a
per-repo integration branch; it buys PR-level granularity for ten extra PRs and two long-lived
branches, and commits give most of the same reading order for none of it.

**The client half is unaffected** and still slices cleanly: the client registry takes routes per
*area*, with no prefix atomicity and no shared models — which is the same asymmetry that let the two
halves be separated in the first place.

##### Why the client half is one slice after all — settled 2026-08-11

The paragraph above is right about the *mechanism* and wrong about the outcome. Routes do slice by
area, but the files behind them do not divide along that line, and the reason is the same rule the
server half taught: **a shared leaf moves with its last consumer.**

`lib/useShardFeed.js` and `lib/shardEvents.js` are imported by eight of the public pages *and* by
three admin views. Splitting public from admin means the module needs them one slice before core is
finished with them, and there are only three ways out — the module vendors a copy for one slice,
public keeps only the four pages that never touch the live feed, or the two slices become one. The
first two both trade a real cost for a boundary that lasts one review, so the client half is **one
slice**: all twelve public pages, every admin and player view, and the leaves under them, in one
`module-uo` PR and one `website` PR.

Two things that were separately tabled fold into it as a consequence, and both are improvements:

- **The nav rows and their feature provider stay one unit.** The nine UO rows in the public header
  carry `feature` gates resolved by `useShardFlags`, which core registers under namespace `uo`.
  Since resolution is by the *registering* module (§1.5), rows that move without their provider
  resolve against a namespace nothing answers for — and everything fails open, so nine rows an
  operator may have disabled or gated to staff would advertise themselves again for the length of a
  slice. `useShardFeatures.js` already says as much in its own closing comment.
- **`VendorSales` was tabled with the public pages and has no public consumer at all.** Its three are
  `AdminCharacters`, `PlayerCharacters` and core's own `UserDetail` — which is the next finding.

##### Core's user-detail page needs a client extension slot — and so does the footer

`UserDetail.jsx` renders a core header, core's `SecurityAdmin`, and then six UO sections. The server
half already had somewhere to put that: Phase 2 PR 4 declared the `admin.users.detail` slot and moved
the six `/admin/users/:id/shard/*` routes behind it. **The client never got the twin** — `registry`
takes routes, nav and feature providers, and nothing else — so the client half had nowhere to put the
same page's other half.

The same gap shows up one component over. `SiteFooter` links to `/site/shard`, a URL the extraction
deletes, and the footer is not nav so no registry answers for it.

Both are the same missing mechanism, and it is **slice 2**, core-only: core declares a slot, at most
one module fills it, and core renders `<Slot>` — nothing when unfilled. The contract is
[API §3.7](MODULE_API.md#37-extension-slots--module-content-inside-a-core-page); `MODULE_API_VERSION`
goes to **1.2.0**.

Two decisions inside it, both settled with the org lead 2026-08-11:

- **The footer slot is named for a place, not for a meaning.** The idea started as "make shard status
  a hook any module can use", which is right, and the only refinement is that core must not learn
  what a game server's status *is*. `site.footer.status` is a position and a bit of styling; the
  label, the target, the data and whether anything renders at all are the module's. A slot typed by
  its content would put game semantics back in core, which is the thing this phase removes.
- **This slice inverts the phase's merge order, once.** Everywhere else `module-uo` merges before
  `website` so `edge` is never missing a feature. Here core must go first, because a module chunk
  cannot call `registry.registerExtension` before the function exists. That is harmless precisely
  because this slice only *adds*: core declares both slots and fills them with its own existing
  components under owner id `core` — the same trick `useShardFlags` and the server's
  `registries.registerCore()` already use — so the rendered page is unchanged and the mechanism is
  proved by core's own content before a line of it moves.

#### Slice 2 — the slots (website#138, 2026-08-11)

`modules/registry.js` grows `declareSlot` / `registerExtension` / `extensionFor`; `modules/Slot.jsx`
is the read side and core's only error boundary. `SiteFooter`'s link becomes `ShardStatusLink.jsx`
and `UserDetail`'s six UO sections become `UserShardSections.jsx`, both registered by `main.jsx`
under owner id `core`. 616 server + **169 client** tests (+9), manifest still 158, swagger
byte-identical.

**The §7.7 browser smoke earned its place again, and this time by finding something no test would
have.** A throwaway module filled both slots: the footer rendered the module's own label and target
in core's `linkStyle`, the admin page received `userId`, and a deliberate render failure was
contained to its own spot with the slot named in the console — but the footer was left showing
`email · · Admin`. The separator was rendered *beside* the slot, guarded on `hasExtension`, which is
right when nothing is installed and wrong when something is installed and fails. Core now decorates
through `<Slot wrap>`, inside the boundary, and `hasExtension` is gone rather than kept as a trap for
the next caller. Every unit test passed both before and after.

Two more things the smoke settled, neither of them a defect:

- **Core's own fill occupies the slot, so a module cannot fill it** — first fill wins and core
  registers first, so the smoke module's call was rejected naming `core`. That is correct and
  temporary: the client half deletes core's two fills in the change that registers the module's. It
  is worth stating in the contract, because a module written against 1.2.0 today cannot use either
  slot.
- **Core's UO sections on the user-detail page now fail to load, and that is slice 1's doing, not
  this slice's** — core stopped serving `/admin/users/:id/shard/*` when the server half left, and
  with no module installed there is nothing at the other end. Identical before and after this change;
  the client half is what puts a live module behind it.

**Criterion 1 is a grep over code, not over prose** — see [API §5.2](MODULE_API.md#52-zero-uo-identifiers-in-core-ci-website-repo)
for what that means precisely. Core's marketing copy says "shard" in a dozen places, and a literal
word grep would have made every one of them a CI failure while proving nothing about the boundary.
Slice 4 rewrites that copy anyway, because a core that still reads as a UO site is not the
game-agnostic platform this workstream is for — but it is a deliberate piece of work with its own
review, not an exemption hidden in a grep pattern.

**One small gap in the kit, deliberately not closed.** The UO client views import almost exactly the
seven §3.4 members — plus `lib/format.js`, a pure leaf formatter. The module **vendors a copy**
rather than core adding an eighth member: the kit is closed on purpose, and a function with no
props and no layout cannot drift the way a component can. The same is not true of `PublicLayout`,
which is why that one is in the kit.

#### Slice 0 — the bundle skeleton (Module-uo#2, 2026-08-11)

`module.json`, an entry point taking `(ctx, api)`, the Vite library build, four shims, and both
boundary checks. It **registers nothing**, and core is untouched — what it proves is the delivery
path itself, before a single UO file moves into it. 29 server tests and 9 client tests, both new.

Verified against a real core rather than asserted: the module loads, mounts its zero routes, reaches
`started` and is published by `/api/v1/public/modules`; its chunk serves from the entry's directory
with `Cache-Control: no-cache` while its server source, `module.json` and `package.json` all 404;
and in Chrome, under the enforced `script-src 'self'`, the chunk reports every shared dependency
**identity-equal** to core's, with zero CSP reports.

**Three findings, each of which had produced a green build that was wrong.** The first amends the
contract and is written up at [API §3.6](MODULE_API.md#36-vite-library-mode-build): `external` and
the aliases do not compose, so `external` is now empty and a resolution-time build guard replaces
it. The second is that guard's own two failures — hooking `load` (first-wins, so it never ran) and
deriving its forbidden list from the alias list (so deleting an alias deleted the guard). Both were
found by breaking an alias on purpose and checking the build actually went red, which is the only
way a guard's absence is visible.

The third is about the boundary check itself and generalises past this repo. **`checkImports.js`
failed on its own documentation** — the comment naming `require("../../etc/passwd")` as an example
of what to catch, and the entry point's comment explaining why a module must never
`require('express')`. A check that cannot survive being described is one people stop writing
comments around, so it strips comments and template literals with a character walk rather than a
regexp (a URL in a string contains a comment opener; a comment contains quotes) and carries its own
test suite. The same applies to slice 4's §5.2 grep, which will be read by a codebase that discusses
modules constantly.

#### Slice 1 — the whole server half (Module-uo#3 + website#137, 2026-08-11)

40 files, ~9,674 lines, 27 of 68 tables, 25 of 82 test files. Core no longer contains anything that
knows what a shard is. Three commits per repo, readable in order.

**The acceptance criterion held exactly.** Core's `routes.manifest.json` goes 228 → 158 public
routes, and the 70 that left reappear byte-identical once the module is loaded — proved by generating
the manifest against core+module and diffing it against the pre-extraction file: zero missing, zero
added, and `routes.guards.json` identical across all 228, so no auth gate moved either.

**`server/core.js` is the port mechanism and the shape is the finding.** Ported code requires its
dependencies at file scope, which runs before `register()` and therefore before any `ctx` exists — so
every member of that file is a stable function resolving `ctx` when *called*, and nothing may be
destructured off `ctx` at init either, because core is free to hand over a getter. That kept the port
to a one-line import change per file instead of a signature change per function. Its consequence:
**require order is load-bearing.** A router does `const express = core.express` at its own file
scope, so `core.init(ctx)` must run before the first `require` under `router/`, and the module's
entry point requires its routers inside `register()` for exactly that reason.

**The contract grew to 1.1.0**, four members, none of which could be avoided:
`ctx.activity.log` (an admin action a module performs belongs in core's *one* audit log — a module
with its own is a second place to look, which means a place nobody looks), `ctx.users.getById`,
`ctx.site.baseUrl`, and `ctx.middleware.rateLimit` + `accountChangeLimiter`. The rate-limit split is
worth restating: a module states its own window and cap because it knows what its endpoints cost, and
takes the plumbing from core so there is one `express-rate-limit` in the process and one place a
breach is logged.

**`registerPostHook` is the fourth registry and the last coupling removed** — see
[API §2.4](MODULE_API.md#24-api--what-the-module-registers).

**What was vendored, and what deliberately was not.** `deriveExcerpt` came across as nine lines of
pure text handling; core's **sanitiser** sitting beside it did not, because a second copy of a
security control diverges silently the moment either is fixed. That is the line: pure leaf helpers
may be copied, controls may not.

**Two defects the extraction exposed, both in core.** The loader matched `CREATE TABLE` against the
**raw** fragment, so a schema file whose header says "every CREATE TABLE carries IF NOT EXISTS" was
rejected for a prefix violation on a table called `carries` — the same class as slice 0's boundary
check failing on its own documentation, and now fixed on both scans by reading split statements. And
the atlas art map resolved `../../../db/data`, correct in core and pointing outside `server/` in the
module: a path that happens to resolve is exactly what survives a green suite, because the
absent-file branch returns `{}` and looks like the normal case. It was caught by the integration run,
not by tests.

**One deliberate behaviour change.** `uoLinkSocket.start()` and the sidecar health probe used to run
*after* the listener bound and now run before it, because `onBoot` does. `start()` returns as soon as
the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and **not**
awaited — an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is
diagnostics; being up is not a precondition for serving a page.

**One test stayed that looked like it should move.** `playerRouteAccess.test.js` guards a real past
bug — an admin 403'd off their own characters — through a now-module-owned URL, but the *guarantee*
is core's: `/player/*` is role-agnostic self-service. It stays and asserts that through
`/player/appeals`. Moving it would have left core with no test of its own tier rule, which is
precisely what regressed once before.

**A note for anyone running core's suite locally: remove `modules/uo` first.** With a module
installed the manifest tests fail correctly — core's committed manifest is core-only, and the live
stack has the module's routes on it.

**One thing to know before running a module locally: the loader skips a *symlinked* module directory
silently.** `readdirSync(…, { withFileTypes: true }).filter(e => e.isDirectory())` reports a Windows
junction as a symlink, so a module linked rather than copied into `modules/` is simply not there,
with nothing logged. Not a defect for a real install — `modules/` is a bind mount of real
directories (§2.5) — but it is the first thing to check when a module fails to appear.

#### Slice 3 — the whole client half (Module-uo#4 + website#139, 2026-08-11)

35 files / 5,332 lines and two of core's thirteen client tests, leaving core with **620 server + 161
client** tests, a manifest unchanged at 158 public + 2 internal, and an OpenAPI diff of exactly one
property. `MODULE_API_VERSION` 1.2.0 → **1.3.0**. The estimate in the table above said 51 files /
~3,700 lines; the real closure is fewer files and more lines, and the table is corrected rather than
the count re-derived to match it.

**The dependency claim held exactly.** Every one of the 35 imports the seven §3.4 kit members plus
`lib/format.js`, and nothing else — so the kit needed no additions and `client/src/core.js` is the
whole boundary. Unlike the server's `core.js` it is a plain read, because `window.__rg` is published
before any module chunk evaluates and there is no gap to defer around; a ported component keeps its
ordinary import shape instead of being wrapped in an accessor that would cost it its identity. Only
`ago()` was vendored, not all six of core's date helpers — the vendoring line from slice 1 (pure leaf
helpers may be copied, security controls may not) plus "copy what you use".

##### The three things core owed first

`player.invite.accepted`, the third slot (API §3.7). `icon` on a nav item (§3.3). And `api.BASE`,
which §3.5 had specified from the first draft and `shared.js` had never published — nothing needed it
until a module had to build an EventSource URL, `request` being fetch-only.

##### `/player` had no index, and the answer generalises

`PlayerCharacters` was the portal's index route *and* its first nav row, both UO. Rather than name a
replacement or invent a landing screen — the org lead is merging the two logged-in areas into one
permission-driven area, so a new core page would be something to unwind — `/player` resolves to the
**first row of the base player nav this viewer can reach** (`firstDestinationFor`, beside
`allowedPathsFor`). With the module installed that is still Characters, so a player's first screen
after signing in does not change; with nothing installed it is Account.

From the BASE nav, never the override-merged one, and here that rule is sharper than it is for
`allowedPathsFor`: an override is presentation, and where everybody lands is behaviour. It also means
an admin cannot move the landing page somewhere a role cannot follow.

##### `game_account_signup` was never core's, and slice 1 had broken it

The setting's help text names *Bridge.cfg* and says the game server's own SignupMode must agree —
core cannot own a sentence about a UO shard. The mode list, the derived public flag, the validation
and the Site Settings field all moved; the **row keeps its key and value**, so a configured instance
finds its mode where it left it, the same grandfathering as `spawn_atlas_servuo_path` (API §6.5).

Moving it surfaced a defect slice 1 had shipped: the ported controller called
`settings.isGameAccountSignupEnabled()`, which `ctx.settings` does not expose, so
`POST /player/shard/account` and its staff twin answered **500 for every caller** and no test in
either repo reached the branch. Both regression tests were shown to fail before the fix went in.

**A known break, accepted:** the shipped Android app reads `gameAccountSignup` off
`/public/settings` (`PublicDto.kt:80`). The field defaults to `false`, so nothing crashes — the app
silently stops offering game-account creation until it reads the module's `/public/shard/features`
instead. Out of scope for this slice and recorded in the Android plan.

*(Amended at the cutover, 2026-08-12: this said the fix "lands well before this workstream's cutover
reaches `main`", and it did not. The org lead released that gate rather than holding the cutover —
the app needs a broader pass than this one field, so waiting bought nothing but delay. The break is
therefore **live** from the cutover, not hypothetical, and the OWED note in `../android/PLAN.md`
carries it.)*

##### What the browser found this time

The §7.7 smoke, run against the real pair, earned its place again. `PlayerPortalLayout` rendered
`<n.icon />` **unguarded** where `AdminLayout` guarded its equivalent — correct for as long as every
row in that sidebar was core's own and had an icon, and **React error #130 with a blank portal** the
moment a module registered a row without one. Core is guarded now and module-uo asserts an icon on
every admin and player row; the public header is text buttons and is excluded.

It also found that a **relative `MODULES_DIR`** — which §7.7's own recipe produces when run from
`server/` — failed every module with `client.entry escapes the module directory`, because
`resolveClient` compares an absolute resolved path against a relative one. `MODULES_DIR` is resolved
absolute now.

Verified in Chrome, zero CSP reports: the public pages under `/uo/*` with core's chrome, the nine
header rows, the six sidebar rows with their glyph in core's Moderation and System groups and My
Characters in its own trailing group, `/admin/uo/link` with the migrated signup field showing the
live value, the user-detail slot filled with real data, the footer slot with its separators intact,
`/player` resolving to `/player/uo/characters`, and the invite flow end to end — core's shell and
skip control, the module's prose and form. The SSE feed connected, which is `api.BASE` working.

##### The nav-override cost, in the wild

Decision 9 (clean break, no nav-override migration) has a visible price and the review instance shows
it exactly. Overrides are keyed by `to`, so all fifteen changed paths orphan and are dropped. On that
instance the operator had gathered the nine UO rows into a **dropdown section**; after the extraction
those keys match nothing, the section is empty, and the nine rows render flat across two lines of the
header. A stored order on a core row that survived (`/site/about` at 8) now interleaves with the
module rows rather than preceding them.

So the release note is stronger than "a hidden row may reappear": **an operator's nav customisation
of the UO rows — order, labels, hidden state and section grouping — is lost and must be redone.**
Accepted rather than migrated, on the grounds that the site is not public yet.

##### Two checks that had never met a real chunk

`checkExternals.js` **rejected the build outright**, naming a fragment of minified JSX as an imported
specifier: a button reading "Approve and import" puts the token immediately before a quote, and no
regexp distinguishes a keyword from the same letters inside a string. The server's `checkImports.js`
hit this from the other side and answered it the same way — a character walk. Here it is a *mask*
rather than a rewrite, because a real import has its keyword outside a string and its specifier
inside one, so blanking strings would take the answer with the noise.

Writing the test for that false positive found the false **negative** underneath it: the pattern
required whitespace after `import`, so it could never see `import{useState}from"react"` — the one
shape a minified build actually emits, and the likeliest way for a missed alias to reach production.
A check tested against only one of its two answers is half a check.

`registration.test.js` is new and closes the gap those two left: stand up a fake `window.__rg` with a
recording registry and the real React, import the **built** chunk, and read back what it asked for —
no DOM, because nothing renders. It holds the agreement that rots quietly (every nav row points at a
route this module registered) rather than restating both lists. CI now builds before it tests, since
both artifact-reading tests skip without a build and were otherwise green while asking nothing.

#### Slice 4 — de-UO core's copy, and enforce it (Module-uo#5 + website#140, 2026-08-11)

The slice that makes criterion 1 a fact rather than a promise. Core ends at **637 server + 157
client** tests, a manifest unchanged at 158 public + 2 internal, and a byte-identical OpenAPI spec.
Three separable pieces, landed together because the check is what keeps the other two true.

##### The bindings were dead a whole slice before anyone noticed

`client/src/api/client.js` still carried **190 lines** of UO namespaces — `shard`, `atlas`, both SSE
URLs, `admin.shard` / `shardOps` / `atlas` / `userShard`, the uo-link and town-crier calls,
`player.shard` — with **zero core consumers** since slice 3 deleted the views that called them.
Nothing failed, nothing warned, and the client build was happy: an API binding with no caller is
inert. Worth naming as a class, because the same thing is true of any leaf a deletion slice leaves
behind, and only the §5.2 check would have found it.

Five assertions went with them. Core's `apiClient.test.js` was still testing UO URLs — the
atlas-vs-`/shard` path split, the query-string filtering, the slug encoding, the admin atlas methods
— so deleting the bindings would have deleted the coverage too. They are `module-uo`'s
`client/test/api.test.js` now, joined by a new one pinning the seven admin URLs the shipped Android
app calls by name. The encoding test that used `governorHistory` was re-pointed at a core route
rather than dropped: what it actually guards is `req`'s encoding, which is core's.

##### The copy, and where a game's name belongs instead

`About`, `Screenshots`, `Website`'s cards, `Wiki`, `SiteFooter`, the default hero, `brand.js`'s
tagline and description, the seeded wiki categories, and two **user-visible** NavEditor strings that
named a module's admin screen by its proper name ("Shard Visibility"). `Status` was the interesting
one: it reports **site mode**, has never had anything to do with a game server, and was called "Shard
Status" purely by habit.

Nothing is lost, because core already has three places an instance says which game it is: `BRAND_*`
(and `.env.uomysticmoon.example` sets both brand strings explicitly, so the live instance's wording
does not change at all), the hero editor, and CMS pages. `heroLayout.defaultLayout` is both the
rendered default *and* the editor's starting point, which is the argument for leaving the words in
code and making them neutral rather than inventing a config surface for prose.

**Wiki page slugs are deliberately untouched.** `seedDefault*` only inserts a row that is absent, so
renaming `maps-atlas` does not rename anything — it adds a ninth page to every existing install.

##### Two settings rows core had no business seeding — one of them broken

`game_account_signup` and `uo_link_protocol_3_migrated` were both seeded by core's `schema.sql`. The
first is the row slice 3 explicitly kept the *key* of; the second is a one-shot migration marker.
Both move to module-uo's fragment, and the second was a live defect.

**The marker fired before the migration that reads it.** Pre-extraction these two statements were
adjacent in core's schema:

```sql
UPDATE uo_link_config SET protocol = 3
  WHERE id = 1 AND protocol < 3
    AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
```

Slice 1 moved the `UPDATE` to module-uo and left the `INSERT` behind. The two files do not run
together — core's schema is replayed **in full** before any module fragment (§2.6) — so the marker
existed before the guard ever read it and the one-shot could never fire. An install carrying a
protocol-2 row would have stayed pinned at 2 against a v3 sidecar: every REST call 409, the WS closed
on `ws.hello`, which is precisely what the migration exists to prevent. Latent rather than live when
it was found, and only because `edge` had not cut over yet: it bites an install that first boots a
post-slice-1 build while already holding a `uo_link_config` row — which is exactly what every
existing instance did at the 2026-08-12 cutover, so the fix landing in this slice rather than after
it is the reason there was nothing to notice.

Verified against a real MariaDB, all three states: a fresh database writes both rows and has nothing
to migrate; a database with `protocol = 2` and no marker is moved to 3 and marked; and a database
deliberately pinned back to 2 **with** the marker present stays at 2 across a restart, which is the
half that makes it a one-shot rather than a re-bump.

`schemaFragment.test.js` asserts the order, because that is the property that broke and no other kind
of test can see it — both files were individually valid SQL and both replayed cleanly. Also gone: an
orphan comment block in core's `schema.sql` describing the spawn-atlas tables slice 1 took away.

##### The check: `scripts/checkModuleIdentifiers.js`

`npm run check:modules`, and the **first** step of the server-tests job — before `npm ci`, because it
is plain Node over `server/` and `client/` source with no dependency of its own, so a boundary break
is the first thing a reviewer sees rather than something found under a pile of unrelated failures.
What it reads is [API §5.2](MODULE_API.md#52-zero-uo-identifiers-in-core-ci-website-repo); what it
learned by getting things wrong first is worth recording:

- **Whole words, not substrings.** A case-insensitive substring pass flags `defaultImage`, which
  contains "ultIma", and it did so four times in this repo on its first run. Names are tokenised on
  camelCase humps and separators, and matched as words — with `uo`+`link` and `town`+`crier` matched
  as adjacent *pairs*, since "link" alone is ordinary core vocabulary.
- **Comments and strings are masked in one character walk**, comments first. This is the
  `checkImports.js` lesson for the third time: a comment contains quotes, a string contains `//`, and
  the two cannot be handled in either order by regexp. Masking rather than deleting, so a route path
  literal is still findable at its own offset and reported line numbers stay honest.
- **It has its own 17-test suite.** A boundary check that silently stops checking is worse than none,
  and every "must not catch" case in it is a false positive an earlier version really produced.
- **The three §6.5 grandfathering allowlists are exempt by name**, with their reason — `LEGACY_TABLE_PREFIXES`
  and `LEGACY_STREAM_IDS`/`LEGACY_LEGS` are maps *keyed by module id*, and there is no way to write
  grandfathering down without naming who is grandfathered. An exemption that stops matching **fails
  the build**: the exemption list is the only part of this check that can weaken it, so it is the part
  that gets the noise.
- **It reads `git ls-files`, not a directory walk.** An operator's gitignored
  `server/db/data/spawnAtlas.art.json` sits in the tree of anyone who has run the atlas import and
  would otherwise report a file-name violation no commit could fix.

**Deferred to slice 5, deliberately: `README.md`.** 48 UO mentions, including a whole
`## Shard integration (uo-link)` section and the architecture diagram. §5.2 does not cover prose, and
core's README is a rewrite that belongs with the phase-closing documentation pass rather than
half-done inside a code slice.

#### Slice 5 — closing the phase, and the obligation nobody had noticed

Module-uo#6 + website#141 + docs#140 (2026-08-11). The slice table words this one as packaging and
documentation: the module's frozen manifest and release workflow, `docs/modules/uo/` and the
`module-rust` dry run. It is also where a **contract obligation that had never been built on either
side** surfaced, and closing it was the larger half.

##### The OpenAPI fragment existed only on paper

[`MODULE_API.md`](MODULE_API.md) §2.8 and §6.1a settle it in detail: a module ships
`swagger-fragment.json`, core merges the fragments of *started* modules into `/api/docs.json` at
request time, core wins every collision. **Neither half was written.** Module-uo's 417 `#swagger`
annotations came across in slice 1 and went nowhere; core's `swagger/mergeSpec.js` named the
request-time caller in its own file header and that caller did not exist. The result was that the 72
URLs module-uo serves were **in no OpenAPI spec at all** — this repo's standing rule (never ship a
route that isn't in the spec) broken by the extraction rather than by a route.

It was folded into this slice rather than deferred to Phase 4, because Phase 3 closing with 72
undocumented routes would have made the phase's own exit criteria untrue.

**The generator derives its prefixes rather than listing them.** `swaggerFragment.js` runs the
module's own `register()` against a recording `api` and asks `require.cache` which file each router
object came from — so a mount prefix lives in `server/index.js` and nowhere else, and swagger-autogen
(which needs a *file*, and cannot follow `api.registerRoutes`) gets pointed at the right one. The two
values it cannot derive, the tier base paths and the extension slot's mount, are §2.4's normative
table — and they are not taken on trust: the frozen-manifest job checks every generated path against
a real core.

**Schemas are namespaced; core's are referenced by core's name.** The 31 UO schemas moved out of
core's `swagger.js` as `Uo…`, because core wins a collision and an un-namespaced `ShardStatus` from a
second game's module would lose to or clobber this one. But the annotations keep pointing at
`#/components/schemas/Error` and `ValidationError` **without** redefining them: those resolve in the
merged document, which is the only place both halves exist. Shipping a copy would be a collision core
correctly drops. Both directions verified against a live merge — 197 paths, no dangling `$ref`.

##### The frozen manifest is a subtraction, not a filter

§5.3 settles that the module's CI clones core at a pinned ref. What it does with that core is the
design decision this slice made: generate the manifest **without** the module and then **with** it,
and take the difference.

Filtering the combined manifest by the module's prefixes would have answered "what does the module
serve". Subtracting answers that *and* "did core lose anything" — and the second is the one §1.2
promises to the shipped Android app and the Discord bot. A module that shadowed or displaced a core
route cannot show up as an addition anywhere; it shows up here as a **removal**. Result: 72 routes
added, **0 removed**.

That job is also the only place in the module's repo where a claim meets ground truth. Everything
else there compares two strings in the same repository; this compares a URL the module registers
against a URL a real Express app reports serving, which is what makes the fragment's coverage check
meaningful rather than self-referential.

##### Releases: the version is declared, not computed

`link` and `installer` both compute the next version from conventional-commit subjects. This module
does not, because it already has one authoritative version — `module.json`'s, which is what core
records in `installed_modules`, shows on the admin screen, and sits beside the `coreApi` range a bump
has to be weighed against. Two sources for one number is how they drift. So: a merge to `main` that
leaves `module.json` at a version with no release yet publishes one, and bumping the version is an
ordinary reviewed PR.

The workflow **never writes to a branch** — it tags and publishes — so `main` needs no push
exception. That is the installer's model, adopted for the reason it was adopted there. The bundle is
assembled from an **include** list rather than an exclude list, because an exclude list ships
whatever it forgot.

##### Six dropped annotations across two repos, and the tool that says `Success`

swagger-autogen reports an annotation it cannot parse and **then prints `Success` in green**, having
skipped it. Nothing was listening in either repo. Making its diagnostics fatal immediately found:

- **core**: `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`, both
  documented with an **empty request body**, both since the day they were written;
- **module-uo**: the same brace-short mistake twice more, plus two descriptions whose inner quoting
  the tool cannot survive — it re-quotes `"` and a backtick to `'` before evaluating, so either
  inside a single-quoted description ends the string early. A typed, described query parameter had
  been silently demoted to an untyped one.

This is the same class as §6.1's silent drop, with one difference: the tool did say something.

**And a seventh defect only a browser could show.** Nineteen descriptions carried an escaped
apostrophe — correct JavaScript, and wrong here, because swagger-autogen does not evaluate the
annotation as JS. The backslash survived into the fragment and Swagger UI rendered it verbatim to a
reader, mid-sentence. The fragment was valid JSON, the paths were right, every test passed. Only
opening the page found it: the §7.7 lesson, in a seam that has nothing to do with the client chunk.

##### The last of core's UO copy

`README.md`'s 48 mentions (the architecture diagram is now core + a module + "the game", and
*Shard integration (uo-link)* is now *Modules*), the four orphan tags and 31 orphan schemas in
`swagger.js`, `info.description`'s "a private Ultima Online shard", and `TOWNCRIER_DURATION_SEC` +
`UOLINK_*` in the two `.env.example`s — module-read, never core-read, and now documented in the
module's own README rather than half-copied in core's.

Auditing the tag list for the four orphans turned up the same defect pointing the other way: five
tags **used** by core routes and never declared (`Admin · Email`, `Admin · Invites`,
`Admin · Moderation`, `Admin · Pages`, `Auth · Me`).

##### The dry run

[`../modules/rust-dryrun.md`](../modules/rust-dryrun.md) — Phase 3's fourth acceptance criterion, and
the only one that could fail in an interesting way. A written `module-rust`, for a game chosen
because it shares almost nothing with UO: it **wipes** monthly, a community runs several **servers**
rather than one shard, its identity is **Steam**, and its server ships **RCON** so there is no
sidecar to write.

The contract generalises — same manifest, same seven registration calls, same schema-fragment rules,
same client registry, and **six of the UI kit's seven members** wanted by a game with nothing in
common with the one the kit was curated from.

It found one real gap: **a module cannot register an identity provider**, and "Sign in with Steam" is
what a Rust community expects. A Rust module can still ship by copying UO's in-game-code flow, one
screen worse. Recorded as the first candidate for a future `MODULE_API_VERSION` bump rather than
bolted on now — an identity provider participates in session creation, which is the one part of core
a module must never be able to weaken, and §2.7's link-only policy has to survive it.

It also found that two rules written for one reason cover another: §2.6's leading-verb allowlist
(written because a fragment replays every boot) correctly forbids the wipe-truncation a Rust author
would put in their schema, and `capabilities` — decoration with one module — is the only thing the
Android app can ask about a module it has never heard of.

##### Phase 3 is complete

Six slices, 2026-08-11. Core is 158 routes and knows nothing about any game; module-uo is 72 routes,
27 tables, 40 server files and 35 client files in its own repo, releasable, and documented in
[`../modules/uo/`](../modules/uo/README.md). Every acceptance criterion in §2.7 is met:

| # | Criterion | Where it is proved |
|---|---|---|
| 1 | No UO identifier in core | `npm run check:modules`, first step of server-tests |
| 2 | Zero internal imports from module into core | `npm run check:imports`, module CI |
| 3 | Route manifest diff is only the deliberate move | core 158 + module 72, **0 core routes removed** |
| 4 | A written `module-rust` dry run | [`../modules/rust-dryrun.md`](../modules/rust-dryrun.md) |

**Phase 4 — Delivery. COMPLETE 2026-08-12**, in five slices. The admin-panel Modules screen (install,
enable, disable, retry, purge, `startup_failed` with its recorded reason) and the Docker-environment
path from §2.5. Deliberately last, so loader, packaging, schema and chunk-loading problems are not
all being debugged at once. Its shape, the six decisions it turned on, the per-slice record and the
acceptance table with its results are in §2.7.2 below — all four criteria met, each against the real
published `module-uo` release rather than a fixture.

**Phase 5 — The Integration Kit. COMPLETE 2026-08-12**, in four slices. `RunicGateway/Integration-kit`, the
instruction book for building a module for a game that is not UO — the website module, the sidecar
and why it is mandatory, and the game-side plugin that feeds it, on top of a `template/` module that
really builds. Scaffolded when Phase 2 lands, written against Phase 3's extraction, started once
Phase 4 closed. Its spec is §2.11; the six decisions it turned on, the slice table and the acceptance
mechanism are §2.11.1.

#### 2.7.2 Phase 4's shape — settled 2026-08-12

Phase 4 is the first phase that is not about the *boundary*. Phases 1–3 answered "can a module exist
and can core live without one"; this one answers "how does a module get onto a box an operator owns,
and how do they get it off again", which is the whole point of §1.14's WordPress constraint.

**Measured starting state.** The producer side is finished and the consumer side is empty.
`module-uo`'s `release.yml` already publishes the three assets an installer wants — a
`module-uo-<version>.tar.gz`, an install manifest `module-uo-<version>.json` carrying
`{id, name, version, coreApi, artifact, url, sha256, size}`, and a `SHA256SUMS` — and `module.json`
already declares `purge: server/db/purge.sql`. Core already has the `installed_modules` columns
`source` and `sha256` (§2.4 carved them out for exactly this and nothing has ever written them
non-null), the live `stateGuard`, and `modules.model.js`'s `enable`/`disable`/`remove`. What core has
**none** of: any code that fetches, verifies, unpacks, removes or purges anything; any admin route;
any client screen; and any resolution of a declared module set at container start —
`docker-compose.yml`'s own comment still tells the operator to untar by hand and restart.

##### Six decisions, settled by the org lead

Decisions 1–4 were settled before slice 0; 5 and 6 came out of writing slice 1 against §2.5 and are
recorded here in the same place rather than buried in a slice write-up, because one of them corrects
a section rather than filling it in.

**1. Restart is a button, not an instruction.** Install, uninstall and re-enable only take effect at
boot, because §1.12 mounts synchronously from the filesystem and the loader is `load()`-once. Rather
than record a pending change and tell the operator to go and restart the container — which
contradicts §2.4's "recoverable from the admin panel, with no shell access to the box" — an admin
action runs `moduleLifecycle.shutdown()` and then exits the process cleanly, and the supervisor
brings it back. The shipped `docker-compose.yml` already declares `restart: unless-stopped` on `app`,
so on the supported deployment this is simply true. It is not universally true — a bare
`npm start --prefix server` does not come back — so the screen says what it is about to do and the
endpoint is not something a stray click reaches.

**2. The install source is a pasted manifest URL, not a catalog.** The admin pastes the URL of a
release's `<module>-<version>.json`; core fetches it, downloads the `artifact` it names, verifies the
`sha256` it declares, and unpacks. There is no index to run and no core release needed to make a new
module installable — which is the WordPress model exactly, and the only shape that lets a module
outside this org be installed at all. The safety is not the catalog, it is the sha256 in the manifest
(module-uo's CHANGELOG already names it "the trust anchor") plus a **host allowlist**: `https` only,
against a configurable list defaulting to the Gitea host. This endpoint installs code that will run
in the server process on the next boot; it is admin-only, rate-limited and audit-logged, and the
allowlist is what stops a pasted URL from being an SSRF primitive as well.

**3. Disable stops the module; enable asks for a restart.** As Phase 2 built it, `disable` flips one
field on the in-memory record and the `stateGuard` starts answering 404 — the module becomes
*invisible*, not *stopped*. For module-uo, concretely, `boot.js`'s `uoLinkSocket` stays connected to
the sidecar and keeps ingesting shard events into `shard_*` tables, and `shardBroadcast`'s SSE
streams stay open, because `onShutdown` is dispatched only from the signal handler. That is
acceptable for "I do not want this feature on the site today" and wrong for the case the button
exists for — "this module is misbehaving and I have no shell". So disable now dispatches **that one
module's** `onShutdown` before flipping the guard, and is a real kill switch.

Enable cannot be its mirror. There is no `onBoot` re-dispatch path, and the contract has never
promised the hooks are re-entrant — no module author has written `onBoot` to be safe to run twice in
one process. Enable therefore flips the row and the screen offers the restart button from decision 1.
Making the hooks re-entrant is the better end state and is recorded as a candidate for a future
`MODULE_API_VERSION` major bump, beside the identity-provider gap §2.7's dry run found; it is not
worth a rewrite of every module's `boot.js` to save one restart.

This is a **change to §2.4**, which described both transitions as pure row-and-guard flips. §2.4's
underlying claim survives untouched: the row still never decides what is *mounted*, only whether a
mounted module answers.

**4. The Docker path is an environment variable.** §2.5 owes "a declarative module set resolved at
container start from the mounted volume, so a compose-managed host is not driven by clicking". That
set is declared in the compose file — the file such a host already edits and version-controls —
rather than in a second config file on the volume. Resolution runs before the server starts and is
**idempotent and offline-safe**: a module already unpacked at the declared version is a no-op, so a
restart with the network down brings the site up exactly as it was. Only a missing or
wrong-version module reaches out, and it reaches out through the same fetch-verify-unpack path the
admin panel uses.

*(Built in slice 3, and one phrase in this paragraph turned out to be the wrong shape. "Resolution
runs before the server starts" is true of the SCAN, not of the process: it runs inside `start()`,
before `app.js` is required, because that is what buys it the database — the host allowlist is a
settings row, and provenance has to be written where the admin route writes it. A pre-flight script
would have had neither. Also settled there: the entry syntax is `<id>@<version>=<manifest URL>`,
with the version written out precisely so the offline no-op is a file read rather than a fetch.)*

**5. Purge is offered inside the uninstall flow, because it cannot be offered after it.** Found while
writing slice 1 against §2.5, which promised a `disabled` row an operator could purge later.
`purge.sql` is a file *inside the module directory*, declared by `module.json` — and uninstall
deletes that directory. There is no later. So the destructive choice is presented at the one moment
the file is still there: the uninstall dialog carries an opt-in "also delete this module's data"
checkbox, alongside the standalone Purge action on a module that is still installed. Both run the
same code and neither is implied by anything. An operator who uninstalls without ticking it keeps
their tables, and reinstalling is how they get the ability to drop them back. §2.5 is amended.

**6. The host allowlist bootstraps from the environment and then lives in the database.**
`MODULE_SOURCE_HOSTS` seeds a settings row the first time the site boots without one; from then on
the setting is authoritative and admin-editable, and changing the variable does not reach back in and
overwrite an operator's choice. This is the shape core already uses for seeded settings — `seedDefault*`
inserts only what is absent — and it means the deployment supplies a sane default without owning the
value forever. The security argument for keeping it in the environment does not survive contact with
what this screen is: an admin who can install a module already has code execution in the process, so
an admin who can name a host has gained nothing they did not have. Changes to it are audit-logged
like every other admin action.

##### The slices

Slice 0 is independent of the rest and lands first; 1–4 are ordered. Unlike Phase 3, most of this is
core-only, so the two-repo merge dance does not apply — `module-uo` is touched only by slice 0.

| # | Slice | Repos |
| --- | --- | --- |
| 0 | **SonarQube for `module-uo`** — `sonar-project.properties` (project key `Module-uo`) plus the scan workflow, mirroring `website`'s. The module is 40 server + 35 client files of extracted code that has never been scanned. | `Module-uo`, `docs` |
| 1 | **The install service and the admin API** — fetch, verify, hardened unpack, remove, purge; `/api/v1/admin/modules` with list, install, enable, disable, uninstall, purge and restart; the per-module `onShutdown` dispatch decision 3 requires; the host allowlist. | `website`, `docs` |
| 2 | **The Modules screen** — `ModulesAdmin.jsx`, its nav row, its API bindings, and the §7.7 browser smoke against a real install of `module-uo` from a real release URL. | `website`, `docs` |
| 3 | **The declarative Docker path** — container-start resolution of decision 4's variable, the compose and image changes it needs, and the operator documentation. The variable is `MODULES`; resolution runs in the server process, before the volume is scanned. | `website`, `docs` |
| 4 | **Close the phase** — `BACKEND_DESIGN.md` §3, the `api-route-inventory.json` mirror, `docs/modules/uo/`'s install instructions, and the acceptance table. | `docs`, `website` |

##### The unpack is the dangerous part, and it is not the download

The download is a solved problem: an allowlisted `https` host, a declared `sha256`, and a size cap.
Unpacking is where the archive gets to choose filenames, and a tar entry may name an absolute path,
escape upward with `..`, be a symlink or hardlink pointing anywhere on the filesystem, or expand to
far more than it appeared to be. Core writes into a **bind-mounted directory it shares with the
host** (Phase 2 PR 9), so an escape is not confined to the container's own filesystem.

The extractor therefore rejects rather than sanitises — an archive that needs correcting is an
archive that should not be trusted — on: any absolute path (POSIX, drive-relative or UNC), any `..`
segment, a NUL byte or a backslash in a path, any entry that is not a regular file or a directory (so
no symlinks, hardlinks, devices or FIFOs), more than one top-level entry, and a total unpacked size
or entry count over its cap. Unpacking goes to a temporary directory beside the target and is moved
into place only once the whole archive has been accepted, so a rejected or interrupted install never
leaves a half-module on the volume for the next boot's scan to find. `tar` is a real dependency
rather than a hand-rolled parser: the format has enough shape (long-name extensions, PAX headers,
sparse entries) that parsing it is exactly the kind of code this list is trying to defend against.

*(Two corrections from building it, in slice 1 below: the top-level directory's NAME is not checked
against the module id — it is `module-uo-<version>`, not `uo` — and `tar` has to be pinned forward
rather than merely depended on.)*

##### What Phase 4 must prove

| # | Criterion | How | Result |
| --- | --- | --- | --- |
| 1 | A module installs, starts and serves with no shell access and nothing built | Admin panel install from a release URL, restart, module `started`, its pages render | **Met** (slice 2). `module-uo` v0.3.0 installed by pasting its manifest URL: five mounts, seven streams, 37 schema statements, `started`, its own nav rows in the sidebar, its pages rendering |
| 2 | Uninstall leaves the data, purge removes it | Uninstall then reinstall recovers the rows; uninstall **with the purge box ticked** then reinstall does not | **Met** (slice 4). On an empty database: uninstall → reinstall kept a marker row and all 27 tables; uninstall with purge → `purged: 27`, every table gone; reinstall → tables recreated **empty**. Standalone purge: `409` while started, `200` while disabled. Found the [ordering defect](#the-defect-criterion-2-found) below |
| 3 | A hostile archive cannot write outside `modules/<id>/` | Unit tests over the rejection list above, each with a real crafted tar | **Met** (slice 1). 16 tests over real crafted tars — absolute, `..`, NUL, backslash, symlink, hardlink, device, multiple top-level entries, oversize, over-count — plus the measured proof that node-tar rejects an escaping member *late*, which is why the inspection is a separate pass |
| 4 | A compose-declared module resolves at container start, offline | Container boots with the network down and an already-unpacked module, and comes up unchanged | **Met** (slice 3). `already at the declared version 0.3.0`, **zero requests**, module up. Two unresolvable declarations logged as errors without stopping the site; uninstall + restart returned the files with the row still `disabled` |

#### Slice 1 — the install service and the admin API (website#142, 2026-08-12)

`modules/archive.js`, `modules/install.js`, `schema.runPurge()`, `lifecycle.stop()`,
`loader.stopHook()` and eight routes under `/api/v1/admin/modules`. 797 server tests (+76), manifest
158 → 166 public + 2 internal, OpenAPI gains eight operations and loses none.

##### The two-pass unpack is measured, not assumed

The reason `archive.js` inspects an entire archive before `tar.x` sees it is not that node-tar fails
to reject an escaping member — it rejects it. It rejects it **late**. An archive whose fourth member
is `mod/../../ESCAPED.txt`, extracted with `strict: true` under node-tar 7.5.22:

```
threw: TAR_ENTRY_ERROR: path contains '..'
LEFT ON DISK after the throw: [ 'first.txt', 'second.txt' ]
```

The loader scans a module directory at require time and asks only whether `module.json` is there
(§1.12), so a half-unpacked bundle is a module as far as the next boot is concerned. A `filter`
callback cannot fix this either: by the time it is asked about entry 400, the first 399 are written.
Hence inspect-then-extract, and hence the scratch directory that is removed on any failure with the
move into place as the last step.

##### `tar` is pinned forward, and its advisory list is the argument

Installing `tar` gets 6.x, which `npm audit` reports as **critical**. Reading the list is the useful
part, because almost every entry is this feature's own threat model: hardlink path traversal via
drive-relative linkpath, symlink poisoning through insufficient path sanitisation, hardlink target
escape through a symlink chain, PAX size override on GNU long-name headers causing a parser
interpretation differential, decompression DoS via unlimited input. The dependency is pinned to
`^7.5.22`, and — more to the point — refusing symlink and hardlink entry types **outright** is what
takes this extractor off most of that list rather than leaving it depending on the library to contain
them.

##### The bundle's top-level directory is not named after the module

`module-uo`'s release workflow packs `dist/module-uo-<version>/`, so §2.7.2's original rule — reject
a top-level directory whose name is not the manifest's `id` — would have refused every real bundle.
Corrected above: exactly one top-level entry is required and that level is **stripped**, because its
name belongs to whoever published the bundle while the directory it lands in has to be the id the
loader scans for. What replaces the check is stronger anyway: the **unpacked** `module.json` must
agree with the install manifest about both id and version, so a manifest promising `uo` and
delivering something else is refused rather than installed under the name it promised.

##### Enable is pinned by a test, because "fixing" it is a one-line change

`lifecycle.stop()` dispatches one module's `onShutdown` and then moves the record — in that order,
since while the hook runs the module is still `started`, the only state in which its routes and the
world it is tearing down agree with each other. A hook that throws does not stop the disable, which
is the opposite of the boot path's rule and deliberate: there a failure means the module never became
safe to use, here the operator has asked for it to stop answering.

There is no `start(id)` beside it, and `enable` deliberately does not touch the loader. That is
invisible in the code — it looks like an omission — so a test asserts `loader.setState` is never
called, with the reason written next to it.

##### Two defects, both of a class already written down

- **The controller destructured `runPurge` at require time.** `const { runPurge } = require(…)`
  captures the function, not the module, so the one dependency whose *order* matters — purge must run
  before the directory containing `purge.sql` is deleted — was the one that could not be substituted
  in a test. Same class as `module-uo/server/core.js`'s never-destructure-a-getter rule. Every other
  import in that file was already a namespace import; the destructure was the odd one out.
- **Two `#swagger` annotations carried an apostrophe inside a quoted string.** swagger-autogen
  re-quotes to `'` before evaluating, so `module's` breaks the annotation and it is dropped. Found
  only because slice 5 taught the generator to fail loudly; the fix is the typographic `’`, as before.

##### Proved against the real release, not a fixture

The published `module-uo` v0.3.0 install manifest, fetched over the real Gitea host and its redirect
chain: sha256 verified, the 252,517-byte artifact inspected and unpacked to 82 files, no scratch
directory left behind — and then core booted against the result and the module registered its five
mounts, seven streams and eight capabilities with its client chunk resolved. That is criterion 1
minus the screen and the restart, both of which arrive in slice 2.

#### Slice 2 — the Modules screen (website#143, 2026-08-12)

`lib/moduleAdmin.js`, `ModulesAdmin.jsx`, its nav row and its API bindings. 182 client tests (+21);
manifest and OpenAPI unchanged.

The presentation logic is plain JS in `lib/moduleAdmin.js` rather than inside the component, for the
reason `lib/adminNav.js` already is: the client's test runner has no DOM, and what is worth testing
here is not the markup but the reconciliation. The JSX renders what it returns.

**Two shapes deliberately unlike the rest of the admin panel.** The restart is a **banner**, not a
per-row button — a restart is a property of the server, and an operator who installed three modules
should restart once. And purge is a **second confirm inside the uninstall flow** (decision 5), so the
destructive choice is never one you agree to by reflex.

##### The row, the loader and the volume are allowed to disagree

This is §2.4 turned into a screen, and it is the one thing here that could not have been simplified.
The row records what the operator decided and what the last boot did; the loader says what is mounted
and answering; the volume says whether there is a directory at all. A screen that picked one and
rendered it would be simpler and would lie — most obviously in the state decision 3 creates on
purpose, where the row says `enabled` and the loader says `disabled` because nothing can start a
stopped module before a restart. Neither "Running" nor "Disabled" is true there.

##### Three defects the browser found, none of them visible to a test

The §7.7 smoke earned its place again, and this time twice over — two of the three are older than
this phase.

- **A fresh install over a failed row rendered the old failure.** Installing on top of a row the
  previous boot had left `startup_failed` displayed "Failed at the require stage: module directory
  not present on the volume" one second after the files had been written to the volume — and because
  that branch is not pending, it **suppressed the restart banner the install had just told the
  operator to use**. Every unit test passed; none had modelled a stale row beside a fresh install.
  The fix is a derivation rather than a special case: the loader scans once at require time, so a
  module on the volume with no live record arrived after that scan and everything the row says about
  it predates the install.
- **The boot refresh had been nulling every install's provenance.** `source` and `sha256` exist for
  this screen and never survived a restart: `lifecycle.boot()` re-records each scanned module with
  neither (correctly — a scan finds a directory, not where it came from) and `upsert` assigned both
  columns unconditionally. So the panel described a module installed from a URL as "placed on the
  volume by hand". **Nothing could have caught it before Phase 4**, because Phase 4 wrote the first
  non-null value those columns ever had — and the model's own test fake reproduced the defect
  faithfully, assigning unconditionally exactly like the SQL. Now `COALESCE(VALUES(col), col)`, with
  tests pinning both directions: a boot must not wipe it, and a re-install from a new URL must still
  replace it.
- **The restart killed the server outright on Windows.** The route reached `server.js`'s
  graceful-shutdown handler with `process.kill(process.pid, 'SIGTERM')`, which is correct on Linux
  and is *unconditional termination* on Windows, where POSIX signals do not exist. No module
  `onShutdown`, no listener close, no pool close, no log flush. `process.on('SIGTERM', …)` is an
  ordinary EventEmitter listener, so `process.emit('SIGTERM')` reaches the same handler on every
  platform without involving the OS. **The test was worse than useless**: it stubbed `process.kill`
  and asserted the call had been made — precisely the call whose *meaning* differs by platform. It
  now waits for the SIGTERM event, which is what `server.js` is actually subscribed to.

The last one is worth generalising: **deployment is Linux containers and would never have shown it.**
A smoke that only ever runs where the code ships cannot find a class of defect that only bites the
people developing it.

##### What the real release proved

The published `module-uo` v0.3.0, installed by pasting its manifest URL into the form: restart, and
the module registered its five mounts and seven streams, replayed 37 schema statements, reached
`started`, and put its own nav rows in the sidebar. Disable then ran its `onShutdown` for real — the
uo-link WebSocket closed, its routes answered 404 and it left `/api/v1/public/modules` — and enable
produced the decision-3 state with the banner. That is acceptance criteria 1 and most of 2, through
the screen, with no shell.

#### Slice 3 — the declarative Docker path (website#144, 2026-08-12)

`server/src/modules/declared.js`, the boot-order change it forced in `server.js`, a fourth source on
the admin screen, and the operator documentation in three files. 741 server tests (+18) and 187
client (+5); manifest unchanged at 166 public + 2 internal, OpenAPI byte-identical.

An operator declares the set their deployment runs, in the environment, and the container arrives at
it by itself:

```
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
```

##### Why the id and the version are written out

The alternative — declaring only the URLs and reading the id and version out of each manifest — is
shorter and cannot answer the question this feature exists to answer offline. **The no-op case must
need no network**, because that is what "a restart with the network down brings the site up exactly
as it was" means, and deciding that a module is already at the declared version by fetching its
manifest is not that. With the version in the variable the check is a `module.json` read on the
volume, and it holds no matter what state the database is in. Only a missing or different version
reaches out, through `modules/install.js` — the same allowlist, the same sha256, the same
inspect-then-extract — and `install()` grew an `expect: {id, version}` so a URL that resolves to
another module or another version is refused **while it is still only a manifest**, before its
artifact is downloaded rather than after it is unpacked.

##### Four decisions, all as recommended

**Resolution runs in the server process, not in a script before it.** It sits in `start()` between
the seed and the require of `app.js`, which buys the database: the host allowlist it installs under
is the same admin-managed settings row the panel uses, and a module it installs gets its `source` and
`sha256` written exactly as the admin route writes them. A compose-installed module and a
panel-installed one are then the same thing on the screen rather than two features that look alike.
It also means a bare `npm start` with `MODULES` set behaves identically; Docker is the reason it
exists, not a special case inside it.

**A failure is loud and not fatal.** An unreachable release host leaves the site serving without that
module, or on the version already unpacked — never in a restart loop, which is what refusing to boot
would mean on a compose host with `restart: unless-stopped`. Core is built to serve with a module
absent (§1.6); a module publisher's outage should not be able to take a shard's website down.

**The declaration owns the volume; the row owns whether a module runs.** An admin who uninstalls a
declared module gets the files back at the next start and the module stays `disabled` until they
enable it, because `upsert` never touches `state`. No tombstone, no precedence rule, and nothing for
the two to fight about — they are answering different questions. The screen says so, which matters
more than it sounds: files reappearing unexplained is an afternoon of debugging.

**And so the admin screen has a fourth source of truth.** §2.4's row / loader / volume become row /
loader / volume / declaration, and it is the only one of the four no button on that screen can
change. A declared module that failed to resolve has *none* of the other three — no row, no
directory, nothing mounted — so without listing it the screen would be identical to one where nobody
had asked for it. The declaration also gets its own line rather than being folded into the status
label, because a module can be running perfectly while its declared upgrade is failing and a single
label would have to discard one of those two facts.

##### The defect this slice created, and the log line that gave it away

Deferring the `require('./app')` is what makes any of this possible — the loader scans and mounts at
require time (§1.12), so that require is the last moment a module can be put on the volume and still
be part of the process. It also moved core's schema *before* the scan, and the module schema-fragment
replay was wired to core's schema. **Every installed module silently got no tables.**

It survived the entire test suite, because every suite here stubs either the loader or the pool. It
survived the first browser smoke too: this machine's development database already had the 27 `shard_*`
tables from earlier phases, so the module started perfectly. The only trace was one line —
`no module scan in this process — skipping schema fragment replay` — which is *correct* output for
`npm run seed` (API §2.6) and means the opposite in a booting server. It was caught by booting
against a brand-new empty database, which is the only place the defect is visible at all.

`ensureSchema()` now takes `replayModules: false` for the one caller that intends to scan later, and
`server.js` replays the fragments itself immediately after the require. There is a `bootOrder` test
that reads `server.js` and asserts the five steps are in the one order they can be in — a structural
test, which proves only that nobody has moved a line, and is worth having precisely because that is
the failure mode: nothing else in the repo can see this one.

##### What the smoke proved, and what it could not

Against the real published `module-uo` v0.3.0, on a fresh database, with the real Gitea release:

| Claim | Result |
| --- | --- |
| A declared module installs and mounts in the **same boot** | 252,517 bytes fetched, sha256 verified, unpacked, 37 schema statements, `started`, `/api/v1/public/shard/status` → 200 |
| A restart at the declared version touches no network | `already at the declared version 0.3.0`, zero requests, module up |
| Two unresolvable declarations do not stop the site | Both 404s logged as errors; site listening; `uo` still serving 0.3.0 |
| Uninstall then restart returns the files and not the module | Directory back with provenance recorded, row still `disabled`, `/api/v1/public/modules` empty, shard routes 404 |

The admin screen's new line was verified through the API rather than the browser — the extension's
viewport came back 0×0 and screenshots failed at the CDP level for the whole session. The live
`GET /admin/modules` carries `declared`, `declaredVersion` and `declaredError` in the two shapes that
matter (a running module whose declared upgrade failed, and a declared module with nothing on the
volume); the rendering above that is one derived line, covered by the client suite. It is the one
thing in this slice not proved in a browser.

#### Slice 4 — closing the phase (website#146, 2026-08-12)

Documentation, the acceptance table above, and one defect the acceptance run turned up. Core's code
change is a three-line reorder; the bulk of the slice is that **`BACKEND_DESIGN.md` had never been
de-UO'd**. Phase 3 rewrote core's *code* and, in slice 5, core's README and OpenAPI metadata — but
§5.2's identifier check reads code, not prose, so nothing ever looked at the design document. It was
still describing 27 `shard_*` tables, 20 UO route rows and the shard visibility ladder as **core's**,
a phase after core stopped being able to serve any of them.

##### What moved, and the rule it follows

| Was | Now |
| --- | --- |
| `BACKEND_DESIGN.md` §3 — six `shard_*` schema sections, 226 lines | [`../modules/uo/SCHEMA.md`](../modules/uo/SCHEMA.md) |
| `BACKEND_DESIGN.md` §4 — 13 public + 7 admin UO route rows, the `/admin/shard` tier prose | [`../modules/uo/API.md`](../modules/uo/API.md) |
| `BACKEND_DESIGN.md` §6.5 — the audience ladder, 70 lines | [`../modules/uo/API.md`](../modules/uo/API.md) §4 |

The text is moved, not rewritten — a relocation that also reworded would make it impossible to tell
which parts changed meaning. What core keeps is the **seam**: `installed_modules`, `/public/modules`,
the eight `/admin/modules` routes, the extension slot, and — new in this slice — a paragraph in each
place saying *a module mounts here as a peer, inherits this group's gate, and owns whatever gate it
adds on top*. §6.5 became "Module-owned audience boundaries", which is core's half of that sentence:
core's security boundary ends at authentication, roles and the session, and a module that serves game
data brings its own.

The general rule, and it is worth stating because the next module will need it: **core's reference
documents core's surface; §2.10 already said module documentation aggregates in `docs/modules/<id>/`,
and that has to include the parts core used to own.** A design doc that keeps describing a module is
a doc that silently becomes wrong the first time an operator runs core without it.

Two stale things fell out on the way, both invisible until the tree was read against the manifest:
`users.router.js` was still listed as 15 routes (it is **9** — six went to the extension slot), and
the push-notification section still promised `config/shardStreams.js` "belongs to module-uo and moves
out with it", in the future tense, four slices after it left.

##### The defect criterion 2 found

Proving criterion 2 meant running the one destructive path nobody had run: install `module-uo` v0.3.0
from the real release onto a **brand-new empty database**, let it build its 27 tables and import a
real atlas and a 67,496-row cliloc table, write a marker row, and then take it all away twice — once
keeping the data and once not.

Both directions were correct. What the run exposed is the **order** the uninstall does it in:

```
purge → stop → removeDir          ← was
stop  → purge → removeDir         ← is
```

`purge.sql` dropped 27 tables while the module was still `started`, and it stayed started until
`lifecycle.stop()` finished — up to the five-second `onShutdown` budget. In that window the module is
serving and ingesting against a schema that no longer exists: module-uo's uo-link WebSocket keeps
writing shard events into dropped tables, and requests in flight answer **500** where a stopped module
answers **404**. The comment justified the old order as *"purge while the SQL is still readable"* —
but `removeDir` is the only step that touches the filesystem, so the file was readable either way.
The dependency chain the comment described was real for one link and imagined for the other.

The trade-off is now stated where the code is: if the purge fails, the module is left
stopped-and-disabled rather than untouched. That is recoverable from the panel; a live module on a
half-dropped schema is not. The 400 for a module shipping no `purge.sql` moved above the stop, so a
refused request stops nothing.

**Generalises: an ordering that is only wrong for a few seconds is invisible to every test and to any
smoke that does not have a live producer.** This one needed a module whose `onBoot` opens a socket
and whose tables are being written continuously — which is to say, it needed the real module against
a real release, not a fixture.

##### What a purge does and does not take

Worth writing down because the answer surprised the run: after purge-and-reinstall the module's
tables come back and the **atlas and cliloc content comes back with them** — 67,496 rows — because
that content is re-derived from the operator's own ServUO tree at the next `onBoot`. What is gone is
everything the shard and its players produced. The two `settings` rows survive on purpose, which
`purge.sql` explains at the top: `uo_link_protocol_3_migrated` is a one-shot migration marker, and
deleting it would re-arm a protocol bump against tables that no longer exist.

### 2.8 SPA URL namespacing — a deliberate break

**Decision: module pages are namespaced, and old paths are not redirected.** The site is not public
yet, so bookmarks, inbound links and configured nav overrides carry no real weight. This buys a
visible boundary in the URL rather than a hidden one.

The rule is that a module owns one path segment wherever it appears:

| Today | After |
| --- | --- |
| `/site/shard`, `/site/atlas`, `/site/market`, `/site/governors`, … | `/uo/shard`, `/uo/atlas`, `/uo/market`, `/uo/governors`, … |
| `/admin/shard-ops`, `/admin/shard`, `/admin/shard-visibility` | `/admin/uo/shard-ops`, `/admin/uo/link`, `/admin/uo/visibility` |
| player shard screens | `/player/uo/…` |

**API URLs are not affected** — they keep their exact paths per §1.2, so the Android app and the
Discord bot need no change for the API.

Two consequences, both accepted:

- **Saved nav-override rows are keyed by `to`** (`utils/navOverrides.js`), so any stored
  `nav_public` / `nav_admin` / `nav_player` customisation stops applying and must be redone. No
  migration is written.
- **`android-app/.../ui/navigation/NavPaths.kt` maps SPA paths to native screens** and holds ten
  `/site/*` constants that will no longer resolve. That is one small Android PR, folded into the
  separate Android module plan. App Links verification itself is unaffected — the manifest's intent
  filters only cover `/mobile/callback` and `auth/callback`.

### 2.9 Branch strategy — `edge`, then one cutover

All website work lands on an **`edge`** branch and reaches `main` as a single cutover at the end,
the same shape used for [protocol v3](../link/v3.md) and the Android theming workstream. Nothing
half-extracted is ever on `main`: a core that has grown a module loader but not yet lost its UO code
is a coherent state, and a core mid-extraction is not.

`edge` does not exist on `website` today — the protocol v3 cutover landed and the branch was cleaned
up, so it is cut fresh from `main`. `Module-uo` develops on its own `main` from its first commit;
it has no cutover to perform, since nothing depends on it until the website cutover lands.

**Phase 0, and it blocks everything: the CI trigger.** `website/.gitea/workflows/pr-checks.yml`
declares:

```yaml
on:
  pull_request:
    branches: [main]
```

So a PR into `edge` runs **no checks at all** — no server tests, no client build, no bot install.
This is the same trap that let all nine Android M12 phase PRs merge with zero CI. It matters more
here than it did there, because Phase 2's exit criterion *is* a CI result: a zero-line
`routes.manifest.json` diff and a passing test suite. Running the whole workstream blind and
discovering the breakage at cutover is the expensive version of this.

The fix is one line — `branches: [main, edge]` — and it must land on `website` `main` **before** the
first module PR, not alongside it. `build-images.yml` is untouched: it triggers on push to `main`, so
images are published and production rolls at the cutover and at no point before it, which is correct.

#### The cutover, as landed — 2026-08-12

[website#150](https://gitea.whitlocktech.com/RunicGateway/website/pulls/150), 52 commits, `main` a
strict ancestor of `edge` so there was nothing to resolve. The branch was deleted afterwards, as the
protocol v3 and installer cutovers did before it: `edge` is cut fresh the next time it is needed, and
a branch left alive is one that collects work by habit.

Four decisions settled it, and the shape of them is worth keeping because none was a code question:

1. **The Android parity gate was released, not met.** `../android/PLAN.md` said the
   `gameAccountSignup` fix "must land before that cutover"; it had not, and the org lead's call was
   that the app needs a broader pass than that one field, so holding the cutover bought delay and
   nothing else. The break is live and recorded in both plans rather than quietly dropped — that is
   the whole difference between an accepted break and a forgotten one.
2. **The UO module was declared before the merge, not installed after it.** UOMysticmoon's tenant
   template gained `MODULES=uo@0.3.0=…` ([website#149](https://gitea.whitlocktech.com/RunicGateway/website/pulls/149)),
   so the compose host resolves its own set at boot (§2.7.2 decision 4). The admin-panel path would
   have worked equally well and left the site game-less between the image roll and someone clicking
   install — for an instance with a shard to be down for, that gap is the entire objection.
3. **The docs status pass rode with the cutover**, this section included, so no document claimed a
   branch that no longer exists.
4. **`Integration-kit`'s pinned core ref moved off `edge`** (§2.11.1), which is the one thing outside
   this repo that the cutover was always going to invalidate.

**What the merge does to a live deployment**, checked against the diff rather than reasoned about,
because this is the only merge in the workstream that rolls production:

- **No `DROP TABLE` anywhere.** Core stops *creating* the 27 `shard_*` tables and never drops them;
  the module's fragment is `CREATE TABLE IF NOT EXISTS`, so an existing instance keeps its shard data
  and its encrypted `uo_link_config` row. The extraction is invisible to the data.
- **One destructive statement in 228 changed files** — phase 2 PR 4's `announce_jobs` column drop,
  guarded on `information_schema`, idempotent, and verified against the live `uomm-db` when it was
  written.
- **No newly-required environment variable**, so nothing fails to boot for want of configuration.
- A declared module **starts on that same first boot**: a fresh `installed_modules` row lands
  `installed`, and `lifecycle.boot()` skips only a row that is explicitly `disabled`. No operator
  click stands between the deploy and a working shard.

The one thing a cutover PR cannot prove is the deploy, which the org lead ran.

### 2.10 Process obligations

Every server-side PR runs `npm run swagger`, `npm run routes:manifest` (the diff is reviewed, not
merely regenerated) and `npm test`, and carries a matching edit to `BACKEND_DESIGN.md`. Module
documentation aggregates in this repo under `docs/modules/<id>/` rather than living in module repos.
Conventional Commits, the AI-disclosure trailer, branches cut from an up-to-date `main`.

**A `MODULE_API_VERSION` bump carries a pass over the Integration Kit** (§2.11.1 d2). The kit's CI
fails on the version number alone, which is the half a machine can check; the half it cannot is
whether a chapter has quietly become untrue, and that is the author's obligation in the same PR.

### 2.11 The Integration Kit — the instruction book for building a module

**`RunicGateway/Integration-kit`** — `https://gitea.whitlocktech.com/RunicGateway/Integration-kit.git`,
**empty as of 2026-08-10**: no branches, no initial commit, exactly where `Module-uo` was at the start
of Phase 0. Its first commit needs the same scaffolding as any other repo here — `README.md`,
`LICENSE.md` (GPL-3.0-or-later), `CONTRIBUTING.md` with the AI-disclosure clause, the PR template.

**Who it is for.** Everything else in this plan is written for someone changing *this* system. The kit
is written for someone building a **new** one: a person who wants Runic Gateway to front a game that
is not Ultima Online, starting from nothing. It is the only document in the project whose audience is
outside the org, and that changes how it is written — it explains and motivates rather than records
decisions.

The job spans all three layers of the data path, which is why it is one book and not a page in each
repo:

1. **The website module.** `module.json`, the server entry point and what `ctx` hands you, the
   `register*` calls, the schema fragment, the prebuilt client chunk and the shared-dependency rule,
   packaging and release CI. The bulk of it.
2. **The sidecar** — what it is, *why*, and that it is **not optional**. The website process never
   opens a connection to a game server; that is a rule in the contract as of MODULE_API 1.4.0
   ([`MODULE_API.md`](MODULE_API.md) §2.7), not a recommendation the kit makes. The game is never
   network-reachable; it dials **out** and the sidecar is the listener; the wire is a versioned
   compatibility contract rather than a build dependency; only the website's backend talks to it.
   The part a message list cannot convey is that the sidecar is a **non-blocking dumb forwarder that
   persists before it forwards** — `uo-link` writes every event and every board snapshot into SQLite
   and answers its REST reads from that store, so a website that is down or restarting loses nothing
   and a page shows the last thing the game said rather than going blank. A new game needs its own
   sidecar or an adapter into the existing one; a game that already ships a remote-control surface
   (Rust's RCON over WebSocket) needs a **thin** one — RCON on the game side, the uo-link-shaped
   HTTP+WS API and the store on the website side — not none.
3. **The game-side plugin** — how a shard feeds the sidecar without ever letting the sidecar stall the
   game: the bounded drop-oldest queue, the dedicated writer thread, world reads only on the game's
   own thread. `servuo-plugins/` is the worked example; the constraints are general, and a plugin that
   ignores them takes the game down when the sidecar wedges.

**The rule that keeps it from rotting: the kit never re-specifies a contract.**
[`MODULE_API.md`](MODULE_API.md) stays normative for the module surface, and
[`../link/PLAN.md`](../link/PLAN.md) + [`../link/INTEGRATION.md`](../link/INTEGRATION.md) for the wire
protocol. The kit *teaches* — worked examples, the reasoning, the order to do things in, the mistakes
that cost time — and links out for the authority. Where it must show a member list it quotes with a
pointer, never a copy. A guide that restates a contract diverges from it silently, and a reader who
follows the divergent copy gets a module that fails validation for reasons the guide cannot explain.

**It cannot be written before the contract is proven**, so it trails the implementation rather than
leading it:

- **Scaffolded once Phase 2 lands** — repo, license, CI, and an outline. By then a loader exists to
  describe and a real module to point at.
- **Written against Phase 3's extraction**, using `Module-uo` as the worked example throughout. A kit
  whose examples are invented is a kit whose examples do not compile.
- Phase 3's fourth acceptance criterion, the written `module-rust` dry run, is really this book's
  first chapter — and doubles as the honest test that the contract generalises past its first module.

**Acceptance:** someone builds a trivial working module for a second game by following the kit alone,
without reading core's source. Until that has happened it is a draft, however finished it looks — and
it says so on its front page.

The org landing page (`RunicGateway/.profile`) and the workspace's `CLAUDE.md` repo table both gain a
row for it — when it has content, not while it is an empty repo.

#### 2.11.1 Phase 5's shape — settled 2026-08-12

**Measured starting state.** `Integration-kit` has **no branches and no initial commit** — still
exactly where `Module-uo` was at the start of Phase 0, two years of this plan's phases later.
Everything it will describe now exists and is proven: the loader, the four registries, the client
registry and its slots, the delivery path with its admin screen and `MODULES` variable, a real
published module (`module-uo` v0.3.0) and a written dry run for a second game. Nothing about the kit
is blocked; what remains is that nobody outside this org has ever tried to use any of it.

**One constraint that shapes every slice: the module system is on `edge`, not `main`.** Decision 11
holds the whole website workstream on `edge` until one cutover, and `main` has no
`server/src/modules/` at all. So the kit's CI pins a core ref **on `edge`**, and the pin is one of
the things the cutover has to revisit — recorded here because a pinned ref that quietly points at an
abandoned branch is exactly the failure this kit is supposed to be immune to.

*(Resolved at the cutover, 2026-08-12. The constraint expired exactly as written: `edge` merged to
`main` and was deleted, so `ci/core-ref.json` moved to `main` at the merge commit. The **sha did not
change** and neither did `MODULE_API_VERSION` — the pinned commit is an ancestor of `main`, so the
`coreApi` equality assertion stays armed at 1.5.0 and no chapter needed re-reading. Only the branch
label was ever wrong, which is the cheap kind of stale and precisely why the pin names a branch
alongside the sha instead of trusting the sha to explain itself.)*

##### Six decisions, settled by the org lead

1. **The kit ships a buildable `template/`, not prose alone.** A minimal module that really compiles
   and really loads: `module.json`, a server entry registering one public route, a client chunk with
   the Vite library build, the anchored alias array and the two boundary guards, a schema fragment
   and release CI. The reader copies it, renames it, and has a working module before they have read
   anything. Prose alone would leave a newcomer reverse-engineering build configuration out of a
   5,332-line real module, and the acceptance test asks for a *working* module, not an understood
   one. The honest cost is that a template is code and code rots, which is what decision 2 answers.
2. **CI is the anti-rot mechanism, and it checks three things.** The kit's `pr-checks` clones
   `website` at a **pinned ref** — the same trick `module-uo`'s frozen-manifest job already uses
   (API §5.3) — then: asserts the kit's declared `coreApi` **equals** that core's
   `MODULE_API_VERSION`, builds the template against it and runs the template's own guards, and
   link-checks every out-link in the book. A contract bump in core therefore breaks the kit's build
   *loudly*, which is the mechanised form of §2.11's own fear: a guide that restates a contract
   diverges from it silently. The rule in prose (§2.10 gains it too) is kept as well, because CI can
   only fail on a number, not on a paragraph that has become untrue.

   **Two more checks were added as the things they guard came into existence, and the pattern is
   worth naming: each one exists because a class of claim in this repo had nothing looking at it.**
   Slice 0 added `checkRenameSites.js`, which holds the template's rename checklist against the
   template tree in both directions — an unlisted file still carrying the placeholder and a listed
   file that no longer does are both failures. Slice 2 added `checkChapterPaths.js`: every path a
   chapter names in backticks must exist. Neither of those is a markdown link, so the link check
   never saw them, and neither is code, so nothing else did — renaming one template file would have
   left four chapters pointing at nothing, silently. Its anchor list (`template/`, `book/`,
   `scripts/`, `ci/`) is **stated rather than derived from the tree**, for the reason API §3.6 gives
   about the template's own build guard: a list derived from what exists cannot fail when what
   exists changes, so a renamed `template/` would simply stop being checked at the moment every
   mention of it became wrong. An anchor that matches nothing therefore fails the build, the same
   rule §5.2's grandfathering exemptions follow.

   All of them together still only answer whether a path resolves. Whether a paragraph has become
   untrue about a file that still exists is a reviewer's obligation on every pull request, and in
   full at a `MODULE_API_VERSION` bump (§2.10).
3. **The sidecar is the default path and the website never contacts a game server.** This overrules
   the "you may not need one" framing that `rust-dryrun.md` arrived at. A sidecar is a **non-blocking
   dumb forwarder that persists before it forwards**: it writes into SQLite and answers reads from
   that store, so a website that is down, restarting or mid-deploy loses no data and a page shows the
   last thing the game said instead of blank. That is not a UO detail — `uo-link`'s `store.rs` holds
   event history, every board's latest snapshot, the economy series and the published ruleset, and
   its live WebSocket feed is deliberately lossy (a lagging consumer drops frames) precisely because
   durability is the store's job and not the socket's. A game that already ships a remote-control
   surface gets a **thin** sidecar, not none.
4. **That rule is contract, not kit teaching** — [`MODULE_API.md`](MODULE_API.md) §2.7, bumping
   `MODULE_API_VERSION` to **1.4.0**. Minor rather than major: no member was added, removed or
   changed, `module-uo`'s `coreApi: "^1.3.0"` still resolves, and module-uo already complies. It is
   the one prohibition in §2.7 with **no CI behind it** — an outbound socket is not statically
   detectable the way an internal `require` is — and it is written down anyway, because otherwise
   every second module re-decides it and the first one to decide wrong finds out during an outage.
5. **Acceptance gets a proxy now and a draft banner until a human replaces it.** The real criterion
   needs a person outside the org and cannot be manufactured. The proxy: a **cold agent given only
   the kit and the contract it links to — never core's source and never `module-uo`** — builds a
   trivial module for a second game, which then goes through API §7.7's browser smoke against a real
   core. It lands as `docs/modules/kit-acceptance.md`, verdict included whichever way it goes. Every
   phase of this plan found its worst defect in that browser smoke rather than in a test; this is the
   same bet, aimed at the documentation instead of the code.
6. **`rust-dryrun.md` §2 is amended with a dated correction.** Its "**No sidecar** — the module dials
   the server directly" is now non-conforming, and §2.11 points newcomers straight at that document
   as the book's first chapter. It is rewritten to a thin RCON sidecar and carries a dated note
   saying what the exercise originally concluded and why it was overruled — corrected rather than
   silently rewritten, because the value of a dry run is the record of what it found.

##### Slices

| # | What | Repos |
| --- | --- | --- |
| — | This plan | `docs` (§2.11.1, API §1.1 + §2.7, `rust-dryrun.md`) + `website` (the 1.4.0 bump, → `edge`) |
| 0 | Scaffold: README with the draft banner, licence, contributing + AI disclosure, PR/issue templates, the book's outline — and the CI of decision 2 | `Integration-kit` |
| 1 | The `template/` module, building green against the pinned core | `Integration-kit` |
| 2 | The book: the website module, the sidecar, the game-side plugin | `Integration-kit` |
| 3 | Close: the cold-agent acceptance run, `.profile` and `CLAUDE.md` rows, the result recorded here | `Integration-kit`, `docs`, `.profile` |

**The template is written before the book, and that is a reversal worth stating.** §2.11 says a kit
whose examples are invented is a kit whose examples do not compile; the strongest form of that is to
build the example first and then write the chapters *out of* a tree CI already proves. It also means
the book's code blocks are quotations with a path next to them rather than prose that happens to look
like code.

**Progress:** slice 0 landed as `Integration-kit`#1, slice 1 as #2 (45 files, module id
`examplegame`, pin moved to website `edge` 1b692bf so the `coreApi` equality assertion arms), slice 2
as #3 — the four chapters, plus `checkChapterPaths.js` (d2 above) — and **slice 3 closes the phase**.

##### Slice 3 — the acceptance run, and what it cost core

The run and its findings are [`../modules/kit-acceptance.md`](../modules/kit-acceptance.md), as d5
requires, verdict included: **yes, with caveats.** A cold agent given the kit and the documents it
links to — never core's source, never `module-uo` — built a working `module-rust` in one pass,
*without opening three of the four normative documents*. It was then installed into a real core on
`edge` and taken through §7.7's browser smoke.

**The finding that justifies the two-stage shape: the agent could not have found the worst defect,
because it had no core to render against.** A module page built exactly as the kit teaches renders
*outside the site* — `PublicLayout` supplies the chrome and not the body, and the `shell-… page-body`
wrapper every core public page writes for itself is two class names that appear in no contract. That
is [`MODULE_API.md`](MODULE_API.md) §3.4's own stated failure ("a module page that does not look like
the site it is installed in") reached by following §3.4. Fixed in core rather than documented at the
reader: `PublicLayout` takes an opt-in `shell` prop, **`MODULE_API_VERSION` 1.5.0** (website#148),
which makes the class names core's business again — the theming workstream owns `theme.css` and a
rename would otherwise break every module's look with nothing failing.

**Second: a kit is not exempt from the thing it is written to prevent.** §2.11's rule is that the kit
never re-specifies a contract, and it held — the agent audited the kit against `MODULE_API.md`
afterwards and found no divergence. But the contract itself said the UI kit was "seven" members over
a table publishing eight, and the kit had faithfully carried that miscount *out*. Fidelity propagates
errors as well as truth; the check for that is a reader, which is what this exercise bought.

**Third, and cheapest to act on: the first command the kit tells a reader to run failed on a pristine
copy.** `check:swagger` compares the committed fragment byte-for-byte, a default Windows clone is
CRLF, and the failure message names a cause that is false ("the routes or their annotations
changed"). Green on the Linux runner forever. The lesson generalises past line endings — **a check
whose failure message asserts a diagnosis has to be right about it**, because a reader who is twenty
minutes in will believe it over their own eyes.

Six other findings, all small, all fixed in the same pass; two recommendations adopted (chapter 1 now
says to run every check on the untouched copy first, which is what found the CRLF defect; and the
template ships the §2.7 self-check the agent wrote for itself — the rule has no CI *in general*, but
a module can make a decidable claim about its own tree).

**The banner does not come off.** d5 makes that a person's to remove and d32 says so; this run
exercised the website-module half only — the module has no sidecar, so chapters 3 and 4 were never
tested — and an agent does not skim, get frustrated, or give up.

**Slice 2's own verification is the shape decision 5 will scale up.** Chapters 1 and 2 quote a tree
CI builds, so what remained unproven was the *walkthrough*: whether following chapter 1 produces what
chapter 1 says it produces. It was run rather than reasoned about — the template copied into a real
core on `edge`, booted, and every claim checked, including the three failures the chapter tells a
reader to cause on purpose (an undeclared prefix → stage `register`; a table missing the id prefix →
stage `schema`, at load, before mounting; a throwing `onBoot` → mounted, `503`). All three came out
as written, and the chapter now quotes core's own messages. **A chapter that predicts the wrong
debugging heuristic is worse than one that predicts none**, and that is exactly the class of error no
check in this repo can see.

---

## Part 3 — Settled decisions

| # | Decision | Where |
| --- | --- | --- |
| 1 | Modules ship idempotent `schema.sql` fragments; no migration runner is built | §1.6 |
| 2 | Website and installer stay independent; delivery is website-side only | §1.11, §2.5 |
| 3 | Core declares an extension slot on `/admin/users/:id`; all six URLs preserved | §1.9 |
| 4 | Phase 1 spike targets `/api/v1/public/atlas/*` | §2.7 |
| 4a | The contract lives in [`MODULE_API.md`](MODULE_API.md); it is normative where the two differ | §2.7 |
| 4b | Modules ship an OpenAPI **fragment**; core merges started modules' fragments into `/api/docs.json` | API §6.1 |
| 4c | Core exposes a **curated, closed** UI kit + request primitive on `window.__rg`, versioned by `MODULE_API_VERSION` | API §3.4 |
| 5 | Install surfaces: admin panel and the Docker environment; never a build step | §2.5 |
| 6 | One repo, one bundle — server and client halves version together | §2.3 |
| 7 | Android app is a separate plan; core owes it `/api/v1/public/modules` | §2.5, §2.7 |
| 8 | SPA pages namespaced: `/uo/*`, `/admin/uo/*`, `/player/uo/*` | §2.8 |
| 9 | Clean break — no redirects, no nav-override migration; site is not public yet | §2.8 |
| 10 | Client half loads as a prebuilt ESM chunk with React shared via a core global | §2.6 |
| 11 | Website work lands on `edge` and reaches `main` as one cutover at the end — **done 2026-08-12**, website#150, `edge` deleted | §2.9 |
| 12 | The module repo is `RunicGateway/Module-uo`; the module id is `uo` | §2.3 |
| 13 | `RunicGateway/Integration-kit` is the module-builder's instruction book — module + sidecar + game plugin, teaching only, never re-specifying a contract | §2.11 |
| 14 | Phase 3 extracts **server-first, then client**, sliced by feature; `module-uo` merges before `website` in each pair | §2.7.1 |
| 15 | Criterion 1's grep reads **code, not prose**; core's UO copy is rewritten in its own slice instead | API §5.2, §2.7.1 |
| 16 | `module-uo`'s CI checks core out at a **pinned ref** to generate its frozen route manifest | API §5.3 |
| 17 | The `module-rust` dry run lands as `docs/modules/rust-dryrun.md`; the Integration Kit links to it | §2.7.1, §2.11 |
| 18 | A module's frozen manifest is the **difference** between a core without it and the same core with it — never a prefix filter | API §5.3 |
| 19 | A module's release version is **declared** in `module.json`, not computed from commit subjects; the workflow tags and publishes and never writes to a branch | §2.7.1 |
| 20 | A module namespaces the schemas it **defines** and references core's shared ones by core's name | API §2.8, §6.1a |
| 21 | Restart is a **button** in the panel, not an instruction — the endpoint raises `SIGTERM` at itself and the supervisor brings the process back | §2.7.2 d1 |
| 22 | The install source is a **pasted install-manifest URL** against a host allowlist, never a catalog | §2.7.2 d2 |
| 23 | Disable dispatches that module's `onShutdown` and is a real kill switch; enable is **not** its mirror and asks for a restart | §2.7.2 d3 |
| 24 | The declarative Docker set is an **environment variable** (`MODULES`), resolved in the server process before `app.js` is required | §2.7.2 d4 |
| 25 | Purge is offered **inside** the uninstall flow, because `purge.sql` lives in the directory being deleted | §2.7.2 d5 |
| 26 | The host allowlist **bootstraps from `MODULE_SOURCE_HOSTS`** into a settings row and is DB-owned thereafter | §2.7.2 d6 |
| 27 | Documentation follows the code out: what core's reference described and no longer serves moves to `docs/modules/<id>/`, text unchanged | §2.7.2 slice 4 |
| 28 | The Integration Kit ships a **buildable `template/` module**, written before the book so the chapters quote a tree CI proves | §2.11.1 d1 |
| 29 | The kit's CI clones core at a **pinned ref** and fails on `coreApi` ≠ `MODULE_API_VERSION`, on a template that stops building, and on a dead link | §2.11.1 d2 |
| 30 | **The sidecar is the default and only path to a game**: a non-blocking dumb forwarder that persists to its own store before forwarding | §2.11.1 d3 |
| 31 | That is **contract, not advice** — API §2.7, `MODULE_API_VERSION` 1.4.0, normative prose with no CI behind it | API §1.1, §2.7 |
| 32 | The kit is a **draft on its own front page** until a person outside the org passes its acceptance; a cold-agent run is the interim proxy | §2.11.1 d5 |
| 33 | `rust-dryrun.md` is **corrected with a dated note**, not silently rewritten — a dry run's value is the record of what it found | §2.11.1 d6 |
| 34 | Core owns the page **body** as well as the chrome: `PublicLayout` takes a `shell` width, and its CSS class names stay core's private business | API §3.4, §2.11.1 slice 3 |
