docs(website): close phase 3 — slice 5, the fragment obligation, and the rust dry run

Records the slice that closes the extraction, and adds the two documents phase 3
owed: docs/modules/uo/ and the module-rust dry run.

**The slice found an obligation neither repo had built.** MODULE_API.md §2.8 and
§6.1a settle the OpenAPI fragment in detail — a module ships one, core merges the
fragments of started modules into /api/docs.json. Neither half existed, so the 72
URLs module-uo serves were in no spec at all. §2.8 and §6.1a now record what was
built, including the four things settled while building it: the filename is fixed
rather than declared, a module namespaces what it DEFINES and references core's
shared schemas by core's name, the generator derives its prefixes from the
module's own register() call, and swagger-autogen's diagnostics have to be
captured because it reports a broken annotation and then prints Success.

**§5.3 gains the design decision the frozen manifest actually made:** it is a
SUBTRACTION, not a prefix filter. Generating the manifest without the module and
then with it answers "what does the module serve" AND "did core lose anything",
and the second is the one §1.2 promises to the shipped Android app. A module that
shadowed a core route cannot appear as an addition anywhere.

**BACKEND_DESIGN.md §4.0.1** is new: /api/docs.json is assembled per request, the
two generated artifacts are core's alone, and the route count was still 228.

**docs/modules/** is new, per §2.10 (module documentation aggregates here, not in
module repos): docs/modules/uo/README.md orients a reader on what module-uo
serves, owns and needs from an operator, and links out to the feature docs that
already existed rather than restating them.

**docs/modules/rust-dryrun.md** is phase 3's fourth acceptance criterion. A
written, deliberately unimplemented module for Rust — chosen because it wipes
monthly, runs several servers rather than one shard, identifies by Steam, and
ships RCON so there is no sidecar to write. The contract generalises: same
manifest, same seven registration calls, same schema rules, 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. Recorded as the first
candidate for a future MODULE_API_VERSION bump rather than bolted on: an identity
provider participates in session creation, and §2.7's link-only SSO policy has to
survive it.

Also: website-README.md refreshed from the repo (it was several changes stale),
and three settled decisions added (18-20).

Pairs with Module-uo#6 and website#141.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:05:56 -05:00
parent 404158b10f
commit 872352389d
7 changed files with 866 additions and 130 deletions

View File

@@ -435,6 +435,33 @@ fragments of started modules into `/api/docs.json`; the full reasoning and the c
§6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered
route has no path in the fragment, and core wins every key collision.
**Built in phase 3 slice 5** (module-uo#6, website#141). Four things settled while building it, all
of which a second module inherits:
- **The filename is fixed here, not declared in `module.json`.** `swagger-fragment.json` in the
bundle root, like `module.json` itself — so a module cannot point core at some other file, and
core's loader has one path to check. A module that ships none is simply absent from the merged
document: whether it registered routes without documenting them is the *module's* CI to answer,
where the routes are known. Core cannot tell a module with no routes from one that forgot.
- **Namespace what you DEFINE; reference core's by core's name.** `UoShardStatus` is defined by the
module; `#/components/schemas/Error` and `ValidationError` are referenced and **not** redefined.
Both resolve in the merged document, which is the only place both halves exist — and shipping a
copy of `Error` would be a collision core drops, arriving at the same result the expensive way.
This is the practical form of "core wins": it makes the two cases feel different in the source,
which is where the mistake would otherwise be invisible.
- **Generate the fragment from the module's own registrations.** swagger-autogen needs a *file* and
cannot follow `api.registerRoutes`, so the module's generator runs its own `register()` against a
recording `api` and resolves each router back to its source through `require.cache`. A mount prefix
then exists in exactly one place. The two values it cannot derive — the tier base paths and the
slot's mount, both §2.4's — are checked against a real core by the §5.3 job rather than trusted.
- **swagger-autogen reports a broken annotation and then succeeds.** It `console.error`s "Syntax
error" or "out of structure", drops that annotation, and prints `Success`. Both repos' generators
now capture those diagnostics and fail on them, which found six annotations documenting less than
they claimed. Two ways one breaks: an object literal a brace short, and a `"` or backtick inside a
single-quoted description (the tool re-quotes both to `'` before evaluating, ending the string
early). A third, which nothing but a rendered page catches: an escaped apostrophe survives
literally, because the annotation is not evaluated as JavaScript.
### 2.9 What core publishes about a module
`GET /api/v1/public/modules` — anonymous, database-free, never site-mode gated.
@@ -1088,6 +1115,29 @@ and runs core's own `routeManifest.js`. Nothing else proves the URLs a module cl
actually serves — a manifest frozen by hand goes stale silently, and the failure it would have caught
is a route that moved.
**Built in slice 5, and what it does with that core is a SUBTRACTION.** The job generates the
manifest without the module and then with it; the difference is what the module serves. Filtering the
combined manifest by the module's prefixes would have answered only "what does the module serve".
Subtracting also answers **"did core lose anything"** — and a module that shadowed or displaced a
core route cannot appear as an addition anywhere, so that is the only way to see it. Three checks
come out of one diff:
1. the added routes match the module's committed `routes.manifest.json`;
2. **no route of core's was removed or changed**, which is `MODULE_SYSTEM.md` §1.2's promise;
3. every added route has an operation in `swagger-fragment.json`, and every operation is an added
route — §2.8's coverage requirement, answered against a running app rather than against a table.
The third is why this job matters beyond the manifest: everything else in a module's repo compares
two strings that live in that repo. This compares a URL the module registers against a URL a real
Express app reports serving, which is the only thing that can catch a fragment that is internally
consistent and describes nothing.
Also learned here: **copy the module into the core checkout, never symlink it.** The loader filters
its scan with `entry.isDirectory()`, which reports a link as a link and skips it silently — the
manifest then comes out with no module routes and the diff looks like a module that registered
nothing. And the client chunk must be **built before** the copy: `client.entry` is validated during
the manifest step of the scan, so a missing chunk is a load failure, not a warning.
Pinning the ref rather than tracking `edge` is what keeps this from being a source of unexplained red
Xes: core moves for reasons that have nothing to do with the module, and a bump is then a deliberate
commit that says which core the module was last proved against.
@@ -1142,6 +1192,25 @@ shipping one with the same name; the collision is logged and the module's versio
`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.
Built as `server/swagger/docsSpec.js` (website#141). Three properties that are contract rather than
implementation, because each one is a way the obvious version is wrong:
- **The committed spec is never mutated.** It is a `require()`d JSON module, so a merge in place
would be permanent for the life of the process *and* cumulative across rebuilds — a module's paths
outliving its own uninstall. Every rebuild starts from a structural copy.
- **The Swagger UI is built per request too**, not bound once while `app.js` is still being required.
Bound at require time it would show core's routes for the life of the process while
`/api/docs.json` showed the merged set — two documents at two URLs, disagreeing.
- **A bad fragment costs that module its paths and nothing else.** Missing, unreadable or not JSON is
logged and skipped; the document still answers. That is §4.4's bargain — one module's failure is
never the site's — and a docs page that 500s is strictly worse than one missing a module's routes.
`started` only, matching `clientEntryUrls()` rather than `clientChunks()`: the document is built when
it is asked for, at which point the state is known, and documenting a module that 503s every one of
those paths sends a client somewhere it cannot go. The cache key is a new
`modules.version()` — a counter the loader bumps on every state *change*, which says nothing about
which module moved or where to.
### 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