MODULE_SYSTEM.md §2.5 and Part 3 record the org lead's rule from 2026-09-23: core's installer refuses a module whose id differs from the one installed, with 409, and accepts only upgrades. MODULE_API.md's registerTeamProvider section says why: a second provider fails that module's whole load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2290 lines
162 KiB
Markdown
2290 lines
162 KiB
Markdown
# 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.
|
||
|
||
**A site runs one module** *(org lead, 2026-09-23; decision 35)*. A site is one game, and the contract
|
||
already has singletons that assume it: `registerTeamProvider` holds **one** value per deployment, and a
|
||
second module registering one fails that module's whole load. With the loader scanning alphabetically,
|
||
installing `rust` beside `uo` would have taken `uo` down, not `rust`. So `modules/install.js` refuses,
|
||
with `409` and before a byte is downloaded, any install whose id differs from a module already on the
|
||
volume. **An upgrade of the installed module is the only install a populated site accepts**; to change
|
||
game, remove the module first. The rule is enforced on the install path both surfaces share, so a
|
||
`MODULES` declaration naming two modules installs the first and reports the second as refused, without
|
||
failing the boot. It does not police a directory an operator places by hand, which remains the
|
||
developer's escape hatch.
|
||
|
||
**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 — *reversed 2026-08-19, see below*
|
||
|
||
`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.
|
||
|
||
*(Reversed 2026-08-19, Module-uo#17. The argument above is still true and the trade it made was
|
||
still wrong, which is worth separating: **its cost is paid on every release, and the drift it
|
||
prevents is something review catches anyway.** Measured, not argued — between the phase 3 release on
|
||
2026-08-12 and this note, **`v0.3.0` was the only release this repo ever cut**, while the nine Teams
|
||
phases and the cutover landed on `main`. Not one of them touched that line, so not one of them
|
||
produced a bundle, and shipping the whole week's work meant first writing, reviewing and merging a
|
||
pull request whose entire content was a number.*
|
||
|
||
*So the engine `link` already runs is adopted here: `feat!:`/`BREAKING CHANGE` → major, `feat:` →
|
||
minor, `fix:`/`perf:` → patch, a `main` that gained none of those → no release. **The number that
|
||
ships is the tag**, and CI writes it into the `module.json` inside the bundle at assembly time, with
|
||
an assertion that the rewrite happened — a bundle carrying the wrong version installs under a number
|
||
that is not the one it was released as. `link` reached exactly this arrangement with `Cargo.toml`,
|
||
and for the reason that governs here too: a release engine that has to commit a bump back to `main`
|
||
stops working the day someone protects the branch.*
|
||
|
||
*Two things are kept rather than replaced. **The declared version survives as a floor** — a
|
||
`module.json` naming a version above the newest tag releases at that version — so the old model is
|
||
not deleted but demoted to the special case it always was, and it is still how a `coreApi` bump
|
||
overrules the subjects. And the workflow **still never writes to a branch**, which was always the
|
||
half of the original design that mattered under branch protection.*
|
||
|
||
*The gap the rules cannot reach — a `module.json` change worth shipping with no releasable code
|
||
behind it, a widened `coreApi`, a new mount, a capability — is a `workflow_dispatch` backdoor:
|
||
blank `version` bumps the newest tag by `bump`, or name an exact version. Where the log and the
|
||
input disagree the **larger** wins, because a button pressed on a log full of `feat:` would
|
||
otherwise publish its `patch` default over a minor's worth of work, and a version that undersells
|
||
its own contents cannot be taken back.)*
|
||
|
||
##### 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~~ — **reversed 2026-08-19**: the version is **derived** from commit subjects, the declaration is a **floor**, and a `workflow_dispatch` backdoor covers a `module.json` change with no releasable code. The workflow still 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 |
|
||
| 35 | **A site runs one module.** The installer refuses a module whose id differs from the one installed (`409`); only upgrades are accepted. Forced by the one-per-deployment Team provider, which a second module would collide with and fail to load | §2.5 |
|