Files
docs/website/MODULE_SYSTEM.md
wtclaude f753dfcff8 docs(website): record slice 2, and the three defects a browser found
The Modules screen as built (website#143), and the three things standing it
up against a live server exposed. Two of them are older than this phase and
neither was reachable from a test.

- A fresh install over a row the previous boot left `startup_failed` rendered
  the old failure - "module directory not present on the volume", one second
  after the files were written there - and, because that branch is not
  pending, suppressed the restart banner the install had just told the
  operator to use. Fixed by 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 anything the row says predates it.

- The boot refresh had been nulling every install's provenance. source and
  sha256 exist for this screen and never survived a restart, because
  lifecycle.boot() re-records with neither and the upsert assigned both
  unconditionally. It could not have been found before Phase 4 wrote the first
  non-null value those columns ever had - and the model's test fake
  reproduced the defect faithfully, assigning exactly like the SQL.

- The restart killed the server outright on Windows. process.kill(pid,
  'SIGTERM') reaches the graceful handler on Linux and is unconditional
  termination where POSIX signals do not exist, so a Windows host got no
  module onShutdown, no pool close and no log flush. process.emit('SIGTERM')
  reaches the same listener everywhere. The test had stubbed process.kill and
  asserted the call - precisely the call whose meaning differs by platform.

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

Also documents the COALESCE in BACKEND_DESIGN's installed_modules section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 03:50:46 -05:00

125 KiB
Raw Blame History

The Module System — design of record

Status: approved design, in implementation — Phase 2's core scaffolding is landing on the website edge branch, PRs 17 of 9 done (§2.7 tracks what each settled). Every decision in Part 3 has been 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 (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.

"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 1321) 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 §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 147169
179 uoLinkSocket.stop()
180 shardBroadcast.closeAll()
719 five top-level requires 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 §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 §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 websitesrc/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 §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-uohttps://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 §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 §3.

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

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

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

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

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

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

2.5 Install, uninstall, purge

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

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

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

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

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

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

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

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 §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 §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.mddone; 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 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 §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_PATHSroles-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 1installed_modules and the state machine, with the stored shape and the boot rules settled in §2.4 above.

  • PR 2server/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: 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 §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: 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 §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 §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 6GET /api/v1/public/modules, the first module-system URL a client can see. Four decisions, all recorded in 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 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 §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 §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 §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 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 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.jsons, 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.mdplus 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.

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 twinregistry 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_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 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: 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.

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, recorded in the Android plan, and it lands well before this workstream's cutover reaches main.

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:

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, and only because edge has not cut over: it bites an install that first boots a post-slice-1 build while already holding a uo_link_config row.

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

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

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

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

The frozen manifest is a subtraction, not a filter

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

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

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

Releases: the version is declared, not computed

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

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

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

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

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

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

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

The last of core's UO copy

README.md's 48 mentions (the architecture diagram is now core + a module + "the game", and Shard integration (uo-link) is now Modules), the four orphan tags and 31 orphan schemas in swagger.js, info.description's "a private Ultima Online shard", and TOWNCRIER_DURATION_SEC + UOLINK_* in the two .env.examples — 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 — 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/. 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

Phase 4 — Delivery. 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, and the four decisions it turned on, are in §2.7.2 below.

Phase 5 — The Integration Kit. 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 exists, and the game-side plugin that feeds it. Scaffolded when Phase 2 lands, written against Phase 3's extraction, finished alongside Phase 4. Full shape and its acceptance test in §2.11.

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

Phase 4 is the first phase that is not about the boundary. Phases 13 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 14 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.

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; 14 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-uosonar-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 screenModulesAdmin.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. website, docs
4 Close the phaseBACKEND_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
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
2 Uninstall leaves the data, purge removes it Uninstall then reinstall recovers the rows; uninstall with the purge box ticked then reinstall does not
3 A hostile archive cannot write outside modules/<id>/ Unit tests over the rejection list above, each with a real crafted tar
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

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.

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 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:

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.

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.

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

RunicGateway/Integration-kithttps://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 and, more importantly, why. The shard is never network-reachable; the shard 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. A new game needs its own sidecar or an adapter into the existing one, and neither can be designed by someone who has been handed the message list and none of the reasoning.
  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 stays normative for the module surface, and ../link/PLAN.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.

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.


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; 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 §2.9
12 The module repo is RunicGateway/Module-uo; the module id is uo §2.3
13 RunicGateway/Integration-kit is the module-builder's instruction book — module + sidecar + game plugin, teaching only, never re-specifying a contract §2.11
14 Phase 3 extracts server-first, then client, sliced by feature; module-uo merges before website in each pair §2.7.1
15 Criterion 1's grep reads code, not prose; core's UO copy is rewritten in its own slice instead API §5.2, §2.7.1
16 module-uo's CI checks core out at a pinned ref to generate its frozen route manifest API §5.3
17 The module-rust dry run lands as docs/modules/rust-dryrun.md; the Integration Kit links to it §2.7.1, §2.11
18 A module's frozen manifest is the difference between a core without it and the same core with it — never a prefix filter API §5.3
19 A module's release version is declared in module.json, not computed from commit subjects; the workflow tags and publishes and never writes to a branch §2.7.1
20 A module namespaces the schemas it defines and references core's shared ones by core's name API §2.8, §6.1a