docs(modules): second contract pass over MODULE_API.md — restores a dropped reconcile, reprices R9 #250
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Status:** approved in outline 2026-09-15, not started. **Eighteen decisions of record, no open
|
**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
|
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.
|
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
|
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 |
|
| # | 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens 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 |
|
| 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 |
|
| 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 |
|
| 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
|
player's phone *and* pops on their screen next login is two mechanisms, and only one of them is
|
||||||
core's.
|
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
|
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|
||||||
|
|||||||
Reference in New Issue
Block a user