docs(modules): phase 2 as built — packaging, release and the frozen manifest #254
@@ -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 |
|
| 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 |
|
| 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** |
|
| 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 |
|
| 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 |
|
| 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.
|
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
|
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||||
|
|||||||
Reference in New Issue
Block a user