docs(modules): phase 2 as built — packaging, release and the frozen manifest #254

Merged
whitlocktech merged 1 commits from docs/rust-phase-2 into main 2026-09-16 10:51:03 +00:00

View File

@@ -979,7 +979,7 @@ Each phase ends with its findings written down, as every workstream here does.
|---|---|---|---|
| 0 | **The rig.****Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.****Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
| 2 | **Packaging and release.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 2 | **Packaging and release.** **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path, on both frameworks.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
@@ -2004,6 +2004,194 @@ API. Anything driving the console has to read a log to know, which is the same c
reached about `oxide.reload` and the same shape R18's rollback needs.
## 15. Phase 2 as built — packaging and release, 2026-09-16
The first phase with no game server in it, and the one that turns a directory somebody copied into a
thing an operator can install. [`Module-Rust#2`][mr] is the whole of it.
**The starting position was worse than the phase row implied.** Phase 1 built five guards and ran
every one of them by hand: the repository had no `.gitea/workflows/` at all. So nothing gated the
branch that gets released, and there was no way to release it. This phase adds both halves, and the
org lead widened it to include the gate rather than only the release.
### 15.0 The three decisions this phase needed
- **D4 — the full CI suite, not only `release.yml`.** The phase row names the release; the repo had
no gate at all. Both ship, and the frozen-manifest job ships with them rather than waiting for a
later phase — see §15.2 for what it found on its first run.
- **D5 — the bundle carries no `node_modules`, and the emptiness is asserted rather than assumed.**
The shipped half declares no runtime dependencies: everything it needs arrives on `ctx`. So the
release runs no `npm ci` and packs no dependency tree. The whole value of that decision is that
*the day it stops being true is a loud day*, so `checkBundle.js` fails the PR that adds a
`dependencies` entry without also teaching the release to install and pack it. Module-uo ships
`ws` and does the opposite; this is a different answer to the same question, not a divergence
from a rule.
- **D6 — no SonarQube for the three Rust repos yet.** Phase 2 is packaging; scanning is quality
tooling and phase 19 is already the sweep where `CLAUDE.md`'s project-key table gets updated.
Three half-populated projects while the repos are skeletons buy nothing.
### 15.1 What is in a release, and what decides its number
A release is **not source**. It is the directory core's loader expects at `modules/rust/`, already
assembled — the prebuilt client chunk, the schema fragment and the OpenAPI fragment, packed as they
will be unpacked — because an operator never builds anything (`MODULE_SYSTEM.md` §1.14).
| Asset | What it is |
|---|---|
| `module-rust-<version>.tar.gz` | the assembled directory, one top-level entry, no `node_modules` |
| `module-rust-<version>.json` | the install manifest: id, name, version, `coreApi`, url, size, **`sha256`** |
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
The version is **derived**, using the engine `link`, `installer` and `Module-uo` already run:
conventional-commit subjects since the newest `v*` tag decide major/minor/patch, nothing releasable
cuts no release, and `module.json`'s version survives as a **floor** rather than as a record. The
number that ships is the **tag**, and CI stamps it into the bundle's own `module.json` at assembly
time — which is why the release also asserts that the assembled `module.json` carries the version
being released, since a bundle that still declared the floor would install under a number that is
not the one it came from.
Module-uo is the argument for deriving rather than declaring: it released only on a hand-edited
version line, and between 2026-08-12 and 2026-08-19 that cost it *every* bundle, because nine phases
of work landed without anyone touching that line. `workflow_dispatch` survives as the backdoor for
the case the rules cannot reach — a widened `coreApi`, a new mount, a capability, with no releasable
code behind it.
**Two failure modes are guarded before anything is built.** The credential check runs first, so a
repository without `REGISTRY_TOKEN` fails with a named error instead of pushing a tag and then 401ing
on the release API — the state `servuo-plugins` got stuck in on its own first release. And a tag that
exists with **no release behind it** is deliberately not treated as "nothing to do": that is the
signature of the same half-failure, and standing down on the tag alone would make it permanent.
### 15.2 The frozen manifest answers the question phase 1 had to take on trust
`§13`'s registration comment says `/rust` "collides with nothing on any of the three tiers, checked
against core's mount tables rather than assumed" — and then names the limit of that check: **core
answers several public routes mounted at the tier root rather than under a prefix**, `/status` and
`/version` among them, which the loader's own collision probe cannot see.
The `frozen-manifest` job is the thing that can. It clones core at the sha pinned in
`ci/core-ref.json`, generates core's route table **without** this module and then **with** it, and
takes the difference. That difference is what the module serves, and it is checked three ways: it
must match the committed `routes.manifest.json`, every route in it must have an operation in
`swagger-fragment.json` *and* every operation must be a route, and — the half that matters most —
**no core route may have been removed or changed**. A module whose mount displaced a core route
cannot show up as an addition, because the URL is unchanged; only the diff sees it.
Run locally against the pinned ref before it was committed, and again in CI against a fresh clone,
both agreeing:
```
route manifest up to date (280 routes)
wrote routes.manifest.json (282 public + 4 internal)
routes.manifest.json is current — 6 routes, all documented
```
Six routes, all documented, nothing of core's moved. **`/rust` is now free by proof rather than by
reading**, and it stays that way on every pull request.
**The pin starts on `main`, unlike Module-uo's.** That repo spent the whole Event System window
pinned to `edge`, because it depended on contract members that had not reached `main` yet. This
module needs `MODULE_API` 1.10.0, which the Event System cutover already put there, so
`ci/core-ref.json` names `efa9db7` on `main` and should stay on `main` until this module comes to
depend on something unreleased.
### 15.3 One declaration, two readers — and it was verified by breaking it
`ci/bundle.json` is an **include** list, never an exclude list: an exclude list ships whatever it
forgot, and the day somebody adds `server/tools/` with a scratch credential in it, an exclude list
packs it and nobody finds out. The cost of that choice is the opposite failure — a new directory
silently drops *out* of every release — which is exactly what happened to Module-uo between v0.3.0
and v1.0.0, where `server/commands/` arrived in a cutover, the list did not learn about it, and the
module installed cleanly and then died at the register stage on the operator's box.
Nothing caught it there because the PR checks copy the **whole repo** into core: they only ever
exercised a tree that had the file. **The subset exists only in the release.** So the list has two
readers — `release.yml`, which packs from it, and `server/scripts/checkBundle.js`, which asks on
every PR whether it still covers everything `server/index.js` can reach.
It reaches requires written **inside `register()`**, which is not a detail: this module's entry point
requires its routers inside the function on purpose, because `core.init(ctx)` has to run before
anything under `router/` is required. A check that only saw file-scope requires would have missed
every router the module has.
Verified the only way a check is worth anything — by breaking it. Dropping `"model"` from the list:
```
ci/bundle.json does not ship everything server/index.js reaches.
server/model (2 files reachable)
Add "model" to ci/bundle.json's server[].
```
It names the exact edit, in the units the list is written in.
### 15.4 The release, as published
`v0.1.0`, cut by the first push to `main`, with no tag before it — so the engine took the first-run
branch and shipped what `module.json` declared. Three assets:
| Asset | Size |
|---|---|
| `module-rust-0.1.0.tar.gz` | 41,310 bytes |
| `module-rust-0.1.0.json` | 355 bytes |
| `SHA256SUMS` | 91 bytes |
Downloaded and hashed independently of CI, the artifact is
`7296c76b988c6191840a1dc4ed1a77d96ad35e8c26f9b6b55e88fe4c10878b32` — byte-for-byte the `sha256` the
manifest declares.
**`REGISTRY_TOKEN` was already configured on the repository**, which was the one prerequisite this
work could not verify for itself: the bot identity can read neither repository nor organisation
Actions secrets (`user should be the owner of the repo`). The credential check exists precisely
because the answer was unknowable from here, and the first release ran clean through it.
### 15.5 The acceptance criterion, walked
> *An operator installs the empty module from Admin → Modules and it reaches `started`.*
**Met.** Walked as an operator would, against the local core on `edge`, with the phase-1 state
deliberately torn down first — the hand-copied `modules/rust/` directory moved aside and its
`installed_modules` row deleted, so this was a first install and not an upgrade. Before it,
`GET /api/v1/public/rust/servers` answered **404**.
1. Admin → Modules, with no `rust` row and `gitea.whitlocktech.com` on the allowlist.
2. Pasted the release's `module-rust-0.1.0.json` URL and pressed Install.
→ *"Installed Rust v0.1.0. Restart to load it."*, and a row reading **Restart to start —
installed, it mounts when the server next starts**, carrying the manifest URL and
`sha256 7296c76b988c…`.
3. Restarted.
→ `registered module "rust" v0.1.0`, `schema ensured for module "rust" {"statements":2}`,
`[rust:boot] booted {"refreshMs":30000}`, `module "rust" started`.
The row is now `state = started` with `started_at` set, the source URL and the released hash — where
phase 1's row had `source` and `sha256` **null**, because a directory somebody copied has no
provenance to record. The screen reads **Running — mounted and serving**.
**What the volume received is exactly the include list and nothing else: twenty files.** No tests, no
`server/scripts/`, no `ci/`, no `client/src/`, no `node_modules`, and no dotfiles. `client/dist/entry.js`
is the chunk CI built.
And the three checks that prove it is *serving* rather than merely loaded:
| | |
|---|---|
| `GET /api/v1/public/rust/servers` | **200**, answering with the rig's `main` server |
| `GET /api/v1/player/rust/servers` · `GET /api/v1/admin/rust/servers` | **401** — the tier gates are on, not bypassed |
| `/api/docs.json` | carries all **five** `/rust` paths: the fragment merged into core's own spec |
**Two things this walk is honest about.** The first is that the *restart* was done from the shell
rather than with the screen's own **Restart the server** button: that button runs the same graceful
shutdown a `SIGTERM` does and relies on a supervisor to bring the process back, which the shipped
`docker-compose.yml` provides and a bare `npm start` does not. The button was not exercised, and it
is core's, not this module's. The second is that the module's two tables were left in place when the
row was deleted, so the rig's `main` server row survived the reinstall — the schema fragment replayed
on boot (`statements: 2`) exactly as R12 requires of a fragment that runs every time.
**The one operational finding, and it is about the rig rather than the module.** Docker Desktop's
Linux engine was found dead — its WSL distribution stopped, the `uomm-db` container exited, and
`docker` answered every call with `500 Internal Server Error` rather than anything naming the cause.
Restarting Docker Desktop and the container fixed it. Worth writing down because the failure presents
as the *website* being broken (`ECONNREFUSED` to a database that is simply not there), and because
`CLAUDE.md` points every smoketest at that one container.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link