diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index f651d92..30e70aa 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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-.tar.gz` | the assembled directory, one top-level entry, no `node_modules` | +| `module-rust-.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