diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index d20b79f..f3937c2 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -2,7 +2,7 @@ **Status:** approved in outline 2026-09-15, not started. **Eighteen decisions of record, no open questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and -engagement catalogues are §9 and §10. +engagement catalogues are §9 and §10; §11 is a second pass over `MODULE_API.md` itself. The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it. This is the document that builds it. Where the two disagree, this one is later and wins — but the dry @@ -674,7 +674,7 @@ Each phase ends with its findings written down, as every workstream here does. | # | Phase | Repos | Done when | |---|---|---|---| | 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set — Kits, Clans, PopupNotifications, ZoneManager (R6, R17) — prove a console grant reaches a plugin | 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.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | 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.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | 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` | | 3 | **The read path.** 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 | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history | | 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 | @@ -687,8 +687,8 @@ Each phase ends with its findings written down, as every workstream here does. | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else | | 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there | | 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking | -| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it | -| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it | +| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it; a wipe reconciles the ledger instead of stranding it | +| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it | | 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates | | 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel | | 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included | @@ -991,4 +991,109 @@ channel core learns about. Worth settling deliberately at phase 10: a raid alert player's phone *and* pops on their screen next login is two mechanisms, and only one of them is core's. +## 11. Second contract pass — `MODULE_API.md` read member by member + +Added 2026-09-15, after docs#249 merged. §7 audited the plan against the **Integration Kit** and the +**template**; this pass reads [`MODULE_API.md`](../../website/MODULE_API.md) itself, enumerating every +member rather than grepping for registration names. It found one regression, one mispriced decision, +one missing declaration and a set of `ctx` members the plan had never mentioned. + +### 11.1 The regression: `reconcile` was dropped + +**`ctx.events.reconcile()` and an action's `reconcile()` appear nowhere in this document.** The +twelve-phase first draft had them — *"phase 6: reconcile, and the boot-id watch"* — and the rewrite to +twenty phases lost them. That is a regression in the plan, not a decision. + +It matters **more** for Rust than for the game the contract was written against. [kit][kit] ch. 5 +rates `reconcile` the one omission that is *"merely a lower standard rather than a broken promise"* — +but that judgement assumes a world that persists. **Rust wipes monthly, and a wipe invalidates every +ledgered resource for that server at once.** Core cannot tell a wedged sidecar from a game that +rebooted and lost everything an event made: it sees `{ ok: false, retry: true }` either way. It asks +once, at its own boot, and otherwise **waits to be told**. + +`ctx.events.reconcile()` is being told, and the thing that triggers it is a **watch on the game's boot +id changing** — which is also the only way to tell a game restart from a sidecar reconnect. They are +not the same event and the second loses nothing. Two rules ride with it: anything that is not an +explicit `{ ok: true, inForce: [...] }` **leaves the ledger alone** — "I do not know" is never read as +"it is gone" — and a resource reported missing becomes `orphaned`, not `reverted`, because nobody +asked for it to go. + +**Restored to phase 13**, after the actions exist, with the boot-id watch as its trigger. + +### 11.2 R9 was mispriced: the visibility framework is `module-uo`'s, not core's + +R9 says the map's per-layer switches work *"through the existing visibility framework +([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md))"*, which reads as reuse. **It is not +reuse.** §6.3 records that `shardVisibility` is **module-owned**, and the tree confirms it — the util, +both models, the admin controller and its tests all live under `module-uo/server/`, and there is +**nothing** by that name left in `website/server`. + +§2.7 forbids a module requiring anything outside its own directory, so `module-rust` cannot import a +line of it. **It builds its own**, informed by UO's design and its document but sharing no code. + +That is a real cost R9 did not price. It is not a reason to change the decision — per-layer switches +are still right, and `SHARD_VISIBILITY.md` is still the design to learn from — but phase 14 carries +a visibility layer of its own rather than a configuration of somebody else's. + +### 11.3 `extensions` is a declared field, not just a call + +R13 claims two slots and never says where they are declared. **`module.json` has an `extensions` +array** (§2.1, optional), and the dry run's own manifest carried `"extensions": ["admin.users.detail"]`. +Like `mounts`, it is a statement of surface that the loader holds against reality — so +`admin.users.detail` and `site.footer.status` are declared there as well as registered. Phase 1 adds +it to the list of `module.json` fields that must be got right. + +### 11.4 The `ctx` members the plan had never named + +`ctx` has **29 members** (§2.3). The plan named a handful. The ones that change work: + +| Member | Where it lands | Why it matters | +|---|---|---| +| **`ctx.secretBox`** | 1 | Each configured server's sidecar token is a secret at rest. Core encrypts its own (AES-256-GCM, write-only in the API, never returned to any client) and hands a module the same facility — so R8's several tokens get the platform's existing posture rather than a new one | +| **`ctx.middleware.rateLimit`** | 6 | R1 requires the link code be rate-limited. This is the mechanism; `accountChangeLimiter` sits beside it for the account-facing half | +| **`ctx.uploads`** | 14 | Where R9's map image actually lands. The plan described fetching it over the bridge and never said where it goes | +| **`ctx.activity.log`** | 7, 7b | R2's permission changes and R18's config writes both owe an audit trail. Core has an activity log; neither needed inventing one | +| **`ctx.teams.publish`**, **`ctx.teams.activity.push`**, **`ctx.teams.reconcile`** | 9 | Teams is more than the provider. The plan named only `registerTeamProvider`, which answers core's questions — these are how a module *pushes* a change and asks for reconciliation | +| **`ctx.posts`** | 10 | The CMS surface behind `registerAnnounceLeg` and `registerPostHook` | +| `ctx.events.emit`, `ctx.inbox.push`, `ctx.push.publish` | 10 | The three send paths §10's catalogue implies and never named | +| `ctx.users.getById`, `ctx.settings.*`, `ctx.validator`, `ctx.db.query`, `ctx.paths.moduleRoot`, `ctx.log`, `ctx.express`, `ctx.auth.getUserFromRequest`, `ctx.site.baseUrl`, `ctx.moduleId` | throughout | Ordinary plumbing; listed so the narrowing is visible | + +`ctx` is **a curated list, not core's internals** — `ctx.auth` is one function rather than core's whole +auth facade, because minting a session is core's job and a module needs to *read* one. Expect to want +something that is not there; that is a minor-version conversation, never a reason to reach around it. + +### 11.5 §6.8 — a trigger, a rule and an audience outlive the module that declared them + +A constraint on phase 10 and on purge that the plan did not carry. + +`engagement_rules.trigger_id` is a plain `VARCHAR` — **no foreign key, no cascade** — deliberately, so +a module can be removed and reinstalled without destroying an operator's rules. The consequence: + +- **A rule whose trigger is unregistered shows `dormant`** — never an error, never auto-deleted. +- **The same for an unregistered audience**: it resolves to the empty set and shows dormant, which is + **not the same answer as "resolved to nobody"** and must not be rendered as if it were. + +The failure that prevents is exact: **an id that stops resolving must never silently become a send to +a different set of people.** + +### 11.6 The two client lists, in full + +Recorded because §7 said "UI kit discipline" without saying what is in it. Both are **closed and +curated** — adding a member is a minor version bump, changing an existing prop is a major one. + +**`registry`** — `registerRoutes`, `registerNav`, `registerExtension`, `registerFeatureProvider`, +`declareModuleSlot` (1.6.0), plus the read side, `routesFor` and `featureProviders`. + +**`ui`** — `PublicLayout`, `PageHeader`, `Loading`, `ErrorState`, `EmptyState`, `useAsync`, `useAuth`, +`useSite`, `Slot` (1.6.0). Anything else — tables, chips, tabs, editors — **your chunk carries it**. + +### 11.7 One confirmation for R10 + +§2.9: `GET /api/v1/public/modules` returns **only `started` modules**, with four fields and no state, +no failure stage and no failure reason. A `disabled` or `startup_failed` module is simply **absent**. + +So R10's capability probe already has the behaviour the app wants: a Rust module that failed to boot +makes the app render a site *without* those screens, rather than one advertising screens that `503`. +The app needs no failure handling for this case because core does not expose the failure. + [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit