Files
docs/website/MODULE_API.md
wtclaude 8db52c3d14 docs(website): settle the OpenAPI fragment merge and the curated UI kit
Both approved by the org lead 2026-08-10. §6.1 moves from open question to
decision A with the per-side obligations spelled out in a new §6.1a: modules
ship a swagger-fragment.json with fully-qualified paths and namespaced schema
keys, core merges started modules' fragments into /api/docs.json at request
time and always wins a key collision, and swagger-output.json stays exactly
what core's own routes generate.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 04:52:59 -05:00

30 KiB

The Module API — the contract

Status: Phase 1 deliverable of MODULE_SYSTEM.md. This document is the normative contract between the core website and an installed module. MODULE_SYSTEM.md decides what the module system is; this decides exactly what a module may call, what it must provide, and what core promises not to break.

Everything below is derived from what the UO code actually does today, re-read against the working tree on 2026-08-10. Where the survey contradicted MODULE_SYSTEM.md, the contradiction is recorded in Part 6 rather than quietly resolved — four of them, one of which (OpenAPI, §6.1) needs a decision before Phase 2 starts.

The one rule everything else serves: a module reaches core only through the members named in this document. Zero require/import from a module to a core file, enforced in CI (§5.1). A core refactor that leaves this contract intact cannot break a module; anything a module needs that is not here extends the contract first, in this file, before the module is written against it.


Part 1 — Versioning

1.1 MODULE_API_VERSION

Core exports a single integer-major semver string from server/src/modules/version.js:

const MODULE_API_VERSION = '1.0.0'

Every module.json declares a coreApi semver range. The loader checks it at boot, before it requires a line of module code, and a mismatch fails that module loudly into startup_failed (§4.4) with the two versions in the reason. It never silently proceeds.

Change Bump
A member is added to ctx, or a new register* call appears minor
A member is removed or its signature changes major
Behaviour of an existing member changes without a signature change major
A core-internal refactor behind an unchanged member none

This is a separate number from PROTOCOL_VERSION, which versions the shard wire and has nothing to say about a website module. It is also separate from the module's own version.

1.2 What is not contract

Core's internal file layout, table names, middleware ordering, the api client object's shape, and every component under client/src/components/ except the ones named in §3.4. A module that reaches any of these is out of contract even if it happens to work.


Part 2 — The server contract

2.1 module.json

Read synchronously by the loader from modules/<id>/module.json. Unknown top-level keys are rejected rather than ignored, so a typo is a loud failure and not a silently-inert setting.

{
  "id": "uo",
  "name": "Ultima Online",
  "version": "1.0.0",
  "coreApi": "^1.0.0",
  "server": "server/index.js",
  "client": { "entry": "client/dist/entry.js" },
  "schema": "server/db/schema.sql",
  "purge": "server/db/purge.sql",
  "mounts": {
    "public": ["/shard", "/atlas"],
    "admin": ["/shard", "/uo-link"],
    "player": ["/shard"]
  },
  "extensions": ["admin.users.detail"],
  "capabilities": ["shard", "atlas", "market"]
}
Key Required Meaning
id yes ^[a-z][a-z0-9-]{1,31}$. The directory name, the installed_modules key, the URL segment, the window.__rg registry key. Must equal the directory it was read from.
name yes Human label for the admin Modules screen.
version yes Semver. Recorded in installed_modules; shown on failure.
coreApi yes Semver range checked against MODULE_API_VERSION (§1.1).
server no Entry point, relative to the module root. Absent ⇒ client-only module.
client.entry no Prebuilt ESM chunk, relative to the module root. Absent ⇒ server-only module.
schema no Idempotent SQL fragment (§2.6).
purge no Destructive teardown (§2.6). Required if schema is present.
mounts no Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch.
extensions no Core extension slots this module mounts into (§2.4).
capabilities no Opaque strings published by GET /api/v1/public/modules, for clients (the SPA, the Android app) to feature-detect against.

2.2 The entry point

server/index.js exports a single function. It is called once, synchronously, during app.js require — not after the database is up.

module.exports = function register(ctx, api) { /* … */ }

It must not await, must not touch the database, and must not throw for a reason that a retry would fix. Everything that needs a live database belongs in onBoot (§2.5). This constraint is not stylistic: scripts/routeManifest.js and swagger/swagger.js both require app.js with the pool pointed at a dead port, and a module that queried at registration time would hang both.

2.3 ctx — what core hands the module

Every member below exists because a UO file uses it today. Nothing is speculative, and nothing that module-uo does not need is on the list.

Member Signature Backed by First real caller
ctx.db.query (sql, params?) => Promise<rows> utils/db every *.db.js
ctx.db.pool mariadb pool utils/db shardAtlas.db.js (streamed import)
ctx.log (namespace) => { error, warn, info, debug }, each (msg, meta?) utils/logger all nine UO utils
ctx.settings.get (key) => Promise<string|null> model/settings shardAtlas.model
ctx.settings.set (key, value) => Promise<void> model/settings shardAtlas.model
ctx.settings.getInstanceName () => Promise<string> model/settings shardIngest.js:84
ctx.auth.getUserFromRequest (req) => { id, username, role } | null utils/auth shardVisibility.js
ctx.push.publish (streamId, { ref?, ownerUserId? }) => Promise<void> utils/pushDispatch:92 shardIngest.js:22
ctx.secretBox { encrypt(s), decrypt(s) } utils/secretBox uoLinkConfig.model
ctx.middleware { requireAuth, requireRole, siteMode, validate, noindex } auth/session.middleware, middleware/* every UO router
ctx.uploads { upload, UPLOAD_DIR, MIME_EXT } admin/imageUpload.js atlas art import
ctx.posts { listAll, getById, linkAnnounceJob, markAnnounced } model/posts newsGump.js:108, announceWorker.js:58
ctx.paths.moduleRoot absolute path to modules/<id>/ loader atlas art, cliloc files
ctx.moduleId the id from module.json loader log tags, table checks

Three narrowings from MODULE_SYSTEM.md §2.1, all deliberate:

  • ctx.auth is one function, not utils/auth. The facade also re-exports signToken, setAuthCookie and the TOTP challenge primitives. Minting sessions is core's job; a module that needs an identity needs to read one.
  • ctx.settings is three functions, not the model. The model exports 24 names, most of them registration/game-signup/app-links policy that is core's business.
  • ctx.posts is four functions. create/update/remove are the CMS, not a module's.

ctx is frozen (Object.freeze, one level deep) before it is handed over. That is a guard against accident, not against a hostile module — per MODULE_SYSTEM.md §2.2 the boundary is organisational, not a security boundary.

2.4 api — what the module registers

The second argument. Every call is synchronous, idempotent-free (calling twice is an error), and validated at once rather than at first use.

api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
api.registerExtension(slot, router)
api.registerNotificationStreams({ streams, mapEvent })
api.registerAnnounceLeg({ leg, dispatch, classify })
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})

registerRoutes(mounts) — one express.Router() per prefix per tier:

api.registerRoutes({
  public: { '/shard': shardRouter, '/atlas': atlasRouter },
  admin:  { '/shard': adminShardRouter, '/uo-link': uoLinkRouter },
  player: { '/shard': playerShardRouter },
})

The keys must match module.json's mounts exactly. Prefixes are validated ^/[a-z0-9][a-z0-9-]*$ — one segment, no nesting, no parameters — and rejected on collision with core's own mount table or with another module's, at registration time. The router is mounted inside the tier, so it structurally cannot reach above its prefix.

The tier gate is already applied. A router registered under admin sits behind noindex, isLoggedIn, requireRole('admin','editor','moderator') from router/v1/admin/index.js; under player, behind noindex, requireAuth; under public, behind nothing, by design. A module adds per-route gates on top of that and never re-implements the tier gate.

registerExtension(slot, router) — the §1.9 case: module routes hanging off a core resource. Only core may declare a slot; a module may only fill one. Exactly one slot exists in v1:

Slot Mounted at Declared by
admin.users.detail /api/v1/admin/users/:id router/v1/admin/users.router.js

The router receives req.params.id from the parent (mergeParams: true). Two modules filling the same slot is a collision and is rejected; core's own routes on the resource always win a path conflict.

registerNotificationStreams({ streams, mapEvent }) — §1.8's push catalog. streams is an array of { id, label, description, scope } appended to core's catalog (ids are namespaced <moduleId>.<name> and rejected otherwise); mapEvent(event) => streamId | null is called by core's dispatcher for events the module's own code publishes.

registerAnnounceLeg({ leg, dispatch, classify }) — §1.8's news dispatcher. leg is a namespaced id, dispatch(post) => Promise<void> delivers, classify(post) => boolean decides whether this leg wants the post. A leg that throws is retried by core's existing per-leg retry and never blocks another leg.

onBoot(fn) / onShutdown(fn) — §2.5.

2.5 Lifecycle

require(module)  →  register(ctx, api)  →  [routes mounted, app.js require returns]
                                              ↓  (server.js, after ensureSchema + seed)
                                          onBoot(ctx)          →  started
                                              ↓  (SIGINT/SIGTERM)
                                          onShutdown()

onBoot is where the eight server.js UO call sites go (MODULE_SYSTEM.md §1.7): the atlas and cliloc refreshes, the market display-name backfill, uoLinkSocket.start(), the sidecar probe. It runs after ensureSchema() (so the module's own tables exist) and after seedDefaults(), and before the HTTP listener binds — a module that must not serve traffic before it has warmed its cache gets that for free.

onShutdown runs before the server closes, in reverse registration order, with a 5-second budget per module; exceeding it is logged and skipped rather than hanging the process.

Both are individually try/caught. An onBoot that throws marks that module startup_failed (§4.4) and the site still comes up — its routes stay mounted but its dispatch guard rejects them with 503, because a module that failed to warm up serving half-initialised data is worse than a module that says it is down.

2.6 Schema fragments

schema is an idempotent .sql file replayed by the same ensureSchema() that replays core's, immediately after it, statement by statement, split the same way. It is subject to the same rules core's file already follows: CREATE TABLE IF NOT EXISTS, ALTER TABLE … ADD COLUMN IF NOT EXISTS, no -- inside a string literal, no DROP.

Table names are namespaced and collision-checked. New tables must be prefixed <id>_. The loader extracts every CREATE TABLE IF NOT EXISTS <name> from the fragment and rejects the module if a name collides with a core table or with another module's — a wrong DROP-free fragment can still silently adopt someone else's table otherwise.

module-uo is grandfathered. Its 27 tables are named shard_* (26) and uo_link_config (1), and renaming them is a data migration this workstream explicitly does not do (MODULE_SYSTEM.md §1.6 puts the count at 25; the working tree says 27 — see §6.4). They are registered in the loader as an explicit legacy allowlist keyed to id: "uo", so the prefix rule holds for every module written after this one.

purge is the destructive counterpart, run only by the explicit admin purge action, never by uninstall. Required whenever schema is present: a module that can create tables and cannot drop them leaves an operator with orphaned data and no supported way to remove it.

2.7 What a module must not do

  • require anything outside its own directory except node built-ins and its own dependencies.
  • Mutate ctx, req.user, or any object core handed it.
  • Register an Express error handler, or any middleware at the app level.
  • Read process.env for core configuration. Its own config is a settings key or its own table.
  • Call process.exit, install signal handlers, or start a listener.
  • Write outside ctx.paths.moduleRoot and the upload directory.

2.8 The OpenAPI fragment

Every module that registers routes ships swagger-fragment.json in its bundle root. Core merges the fragments of started modules into /api/docs.json; the full reasoning and the collision rules are §6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered route has no path in the fragment, and core wins every key collision.


Part 3 — The client contract

3.1 How the chunk gets there

Exactly as MODULE_SYSTEM.md §2.6 resolved, and Phase 1's spike is what proves it:

  1. Module CI builds client/dist/entry.js with Vite in library mode, react, react-dom, react-dom/client and react-router-dom declared external.
  2. Core serves the module directory statically at /modules/<id>/ — same-origin, so script-src 'self' (config/csp.js:49) admits it with no nonce and no inline.
  3. utils/htmlShell.js injects <script type="module" src="/modules/<id>/entry.js"> at the </head> rewrite it already performs (line 111), for each started module.
  4. Before that tag, core has published window.__rg (§3.2) from its own bundle. The module's externals resolve against it.

There is exactly one React instance and core owns it. A module that bundles its own React will produce two copies of the hook dispatcher and fail at the first useState; the externals config in §3.5 is what prevents it.

3.2 window.__rg

Populated by core's main.jsx before it renders, and frozen afterwards.

window.__rg = {
  version: '1.0.0',       // MODULE_API_VERSION — the same number as the server's
  react,                  // the React namespace
  reactDom,               // react-dom/client
  router,                 // react-router-dom namespace
  registry,               // §3.3
  ui,                     // §3.4
  api,                    // §3.5
}

A module entry checks window.__rg.version against its own coreApi range and refuses to register on a mismatch, logging once — the client-side twin of §1.1, and the reason version is here at all.

3.3 registry — what the module registers

registry.registerRoutes(id, { public: [...], admin: [...], player: [...] })
registry.registerNav(id, { area, items })
registry.registerFeatureProvider(id, namespace, hook)

registerRoutes — arrays of { path, element, gate? }. Paths are relative to the module's namespace and core prefixes them (MODULE_SYSTEM.md §2.8):

Area Rendered at Wrapped in
public /<id>/<path> MaintenanceGate
admin /admin/<id>/<path> RequireAuth + AdminLayout
player /player/<id>/<path> RequirePlayer + PlayerPortalLayout

gate is an optional { roles: [...] }, applied by core as the existing RoleGate. A module cannot supply its own auth wrapper — that is the one place where the client boundary is load-bearing, since the sidebar and the route table must agree about who may see what.

App.jsx stops being a flat static table and becomes core's routes plus registry.routesFor(area). Registration happens at entry-script evaluation, which is before createRoot().render(), so nothing renders against a half-populated registry.

registerNav — items interleave into core groups (MODULE_SYSTEM.md §1.4):

registry.registerNav('uo', {
  area: 'admin',
  items: [
    { label: 'Shard ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30,
      roles: ['admin', 'moderator'] },
    { label: 'Shard',     to: '/admin/uo/link',      group: 'System',     order: 10 },
  ],
})

group names an existing core group; an unknown group name appends a new group at the end rather than dropping the item. order sorts within the group, core items keeping their current positions. feature (public area only) names a flag resolved by §3.3's provider.

MOD_PATHS in AdminLayout.jsx:109 — today a hardcoded allowlist of two UO paths — becomes a computation over each item's roles, so moderator visibility follows from the registration instead of from a second list that has to be kept in sync.

The pipeline is unchanged from THEMING_AND_NAV.md, with one new first step:

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

registerFeatureProvider — core keeps a generic flag context; the module supplies the hook that fills its namespace (useShardFeatures for uo). With no module installed the filter is a correct no-op, because no core nav item carries a feature today.

3.4 ui — the shared component kit

This is the largest addition Phase 1 makes to the plan, and it is not optional (§6.2; approved 2026-08-10). The atlas pages alone import five core modules that are not React and not the router: PublicLayout, PageHeader, Loading / ErrorState / EmptyState, and useAsync. Without a shared kit a module either reaches into core's tree (violating the zero-import rule) or ships its own copies, which means a module page that does not look like the site it is installed in — and drifts further every time core's layout changes.

The kit is curated and closed, not a re-export of components/:

Export From Why it is in the kit
PublicLayout components/PublicLayout.jsx the public chrome; a module page without it is a bare page
AdminPage routes/admin/… the admin content frame
PageHeader components/PageHeader.jsx title/subtitle furniture
Loading, ErrorState, EmptyState components/PageState.jsx the three states every data page has
useAsync lib/useAsync.js the fetch/loading/error hook every data page uses
useAuth, useSite contexts/* read-only access to session and site settings

Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself. Adding to the kit is a minor MODULE_API_VERSION bump; changing a kit component's props is a major one. That is a real constraint on core and it is the price of the boundary being worth anything.

3.5 api — the request primitive

client/src/api/client.js is one 518-line object, and it already carries module namespaces: api.atlas (line 196) and api.shard are UO bindings living in core's client. They move out with the module.

Core exposes the primitive, not the object:

window.__rg.api = { request, ApiError, BASE }   // request(path, { method, body, headers, raw })

request is client.js's existing req — same-origin /api/v1, credentials: 'include', JSON in/out, throwing ApiError(status, message, body). A module builds its own namespace over it and owns the paths it calls, which is correct: it owns the routes at the other end.

3.6 Vite library-mode build

The module's vite.config.js, and the four externals are the whole contract:

export default defineConfig({
  plugins: [react()],
  build: {
    lib: { entry: 'src/entry.jsx', formats: ['es'], fileName: () => 'entry.js' },
    outDir: 'dist',
    modulePreload: { polyfill: false },   // same reason as core: no inline bootstrap under CSP
    rollupOptions: {
      external: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
      output: { paths: { /* rewritten to window.__rg by the shim below */ } },
    },
  },
})

Rollup's external alone emits bare import 'react' specifiers, which the browser cannot resolve without an import map — and CSP forbids the inline <script type="importmap"> that would provide one (MODULE_SYSTEM.md §1.14). The module therefore ships a two-line shim module that re-exports from the global, and aliases the four externals to it:

// src/shim/react.js
export default window.__rg.react
export const { useState, useEffect, useMemo, useCallback, useRef, createElement, Fragment } = window.__rg.react

This is the highest-risk mechanical detail in the whole plan and it is exactly what the Phase 1 spike exists to prove. If it does not hold, §2.6 of the design of record is wrong and the client half needs rethinking before Phase 2 builds on it.


Part 4 — The loader's obligations

4.1 Synchronous, filesystem-sourced

app.js scans modules/*/module.json with fs.readdirSync at require time and mounts what it finds (MODULE_SYSTEM.md §1.12). The database is not consulted. MODULES_DIR defaults to <repo>/modules and is overridable by env for tests and for the Docker volume mount.

4.2 Order

Alphabetical by id, deterministically. There is no dependency resolution between modules (§2.0 of the design of record puts it out of scope) and alphabetical order is the honest way to say so — any other order would imply a precedence that is not being computed.

4.3 Validation, in this order

  1. module.json parses; no unknown keys; id matches the directory.
  2. coreApi satisfied by MODULE_API_VERSION.
  3. Declared mounts prefixes are well-formed and collide with nothing.
  4. Declared extensions slots all exist.
  5. schema/purge files exist and are readable; table names are namespaced or allowlisted.
  6. require(server) succeeds and exports a function.
  7. register(ctx, api) returns without throwing, and registers exactly what module.json declared.

A failure at any step is that module's failure and nobody else's.

4.4 startup_failed is a state, not a crash

Per MODULE_SYSTEM.md §2.4, the loader try/catches the entire lifecycle — require, validation, registration, schema replay, onBoot — and any failure marks the module startup_failed with the reason recorded in installed_modules, visible in the admin panel, recoverable without shell access. The site comes up.

Two sub-cases differ, and the difference matters:

Failure before routes are mounted Failure after (schema, onBoot)
routes and nav are simply absent routes stay mounted; the dispatch guard returns 503

The second is what keeps the URL surface deterministic and generatable: routes.manifest.json must not depend on whether a module's boot hook happened to succeed on the machine that generated it.

4.5 The disabled guard

A module disabled in installed_modules is mounted and guarded, never unmounted — a one-line if (!enabled) return res.status(404) ahead of its tier mount. Same reason: the URL surface is a property of the filesystem, not of a database row.


Part 5 — Enforcement

5.1 Zero internal imports (CI, module repo)

The acceptance test for the whole contract. In the module's own CI:

grep -rE "require\(['\"]\.\./\.\./|from ['\"]\.\./\.\./\.\./" server/ client/src/

— refined to "no relative path that escapes the module root", plus a check that the only bare specifiers in the client bundle are the four declared externals. A hit fails the build.

5.2 Zero UO identifiers in core (CI, website repo)

Phase 3's acceptance criterion 1: no shard, uoLink, cliloc, atlas or towncrier outside modules/, as a CI grep test rather than a review promise.

5.3 Zero-line route manifest diff (CI, both repos)

npm run routes:manifest -- --check in core; the module generates and freezes its own manifest in its own repo, using the same script pointed at a core+module app. Phase 2 must produce a zero-line diff in core's; Phase 3 moves the UO entries out of core's and into module-uo's, which is the one diff the whole workstream is allowed.


Part 6 — Amendments to MODULE_SYSTEM.md

Four things the survey found that the design of record gets wrong or does not cover. The first needs a decision.

6.1 OpenAPI generation does not survive a dynamic loader — settled: fragment merge

MODULE_SYSTEM.md §1.12 treats scripts/routeManifest.js and swagger/swagger.js as the same problem, because both require app.js with no database. They are not the same problem.

  • routeManifest.js walks the live Express stack (app._router.stack, line 175). It is runtime introspection and a filesystem-scanning loader is invisible to it in the best way: whatever got mounted, it sees.
  • swagger/swagger.js is static analysis. swagger-autogen is handed routes = ['./src/app.js'] (line 29) and parses the source text, following app.use(...) to the required file. A require(path.join(dir, manifest.server)) inside a for loop is not statically resolvable. Module routes will be absent from swagger-output.json — silently, with no error.

That collides directly with CLAUDE.md's standing rule: never ship a route that isn't in the OpenAPI spec. Three ways out:

Option How Cost
A. Fragment merge Module CI runs swagger-autogen against its own server/index.js and ships swagger-fragment.json in the bundle. Core deep-merges the fragments of started modules into /api/docs.json at request time. one merge helper in core (~40 lines); the module owns its own spec, which matches "one repo, one bundle"
B. Glob the modules dir Core's swagger.js adds modules/*/server/index.js to routes when present. core's committed spec then depends on which modules the developer had checked out — a spec that differs per machine
C. Hand-write module paths into core's spec a second source of truth; drifts on the first module release

Decided 2026-08-10: A. It is the only one that keeps the spec correct on an operator's box, where core is a prebuilt image and the module arrived afterwards. The obligation this puts on a module is §2.8; the one it puts on core is Phase 2 item 2.

6.1a The obligation, on each side

Module: a swagger-fragment.json in the bundle root, generated by its own CI with the same swagger-autogen tooling pointed at its own entry point, carrying only paths, tags and components.schemas. Its paths must be fully qualified (/api/v1/public/atlas/creatures), because the module knows its own mount prefixes and core does not re-derive them. Its components.schemas keys are namespaced (UoAtlasCreature, not AtlasCreature) so two modules cannot collide in the merged spec. CI fails the module build if a route it registers has no path in its fragment — the per-module form of "never ship a route that isn't in the spec".

Core: /api/docs.json merges the fragments of started modules over its own committed spec at request time (cached, invalidated on a module state change). Merge is shallow-per-section and core always wins a key collision — a module cannot redefine a core path, tag or schema by shipping one with the same name; the collision is logged and the module's version dropped. swagger-output.json itself stays exactly what core's own routes generate, so npm run swagger remains reproducible on any machine regardless of what is installed.

6.2 The client contract is much larger than §2.1 says

§2.1 lists three client registration calls and nothing else, implying React and the router are all a module needs. The atlas pages disprove it: Atlas.jsx imports PublicLayout, PageHeader, PageState's three states, useAsync and api — five core modules beyond React, on the smallest UO page. Hence §3.4's curated UI kit and §3.5's request primitive. This is an addition to the contract, not a change of direction, but it makes core's component API a versioned surface, which it has never been before.

6.3 shardVisibility is module-owned, and core's nav depends on it

utils/shardVisibility.js is a UO util that provides requireFeature and project — and the atlas routes, the spike target, are gated by it (atlas.router.js:25). So the spike carries shardVisibility plus the shardVisibility and shardLinks models with it, not just the atlas files. Two consequences:

  • On the server this is fine: requireFeature becomes module-internal middleware, and only PUBLIC_KINDS crosses the boundary — already handled by §1.8's registerNotificationStreams inversion.
  • During the spike there will be two copies of shardVisibility in one process (the module's, and core's for the not-yet-extracted shard routes) with two independent 5-second caches over the same table. Functionally identical, and a spike-only artifact that Phase 3 resolves by moving the original. Recorded so it is not mistaken for a design flaw.

6.4 Two counts in §1.6 and §2.7 are off

  • 27 UO tables, not 25: 26 shard_* plus uo_link_config. §1.6 says "the 24 shard_* tables plus uo_link_config".
  • The atlas spike is 6 routes, not 5: /creatures, /creatures/:slug, /regions, /landmarks, /champions, /meta.

Neither changes a decision; both are corrected here rather than left to be tripped over when the extraction is counted against the plan.