docs(modules): a second contract pass over MODULE_API.md, member by member

Section 7 audited the plan against the Integration Kit and the template. This
pass reads MODULE_API.md itself and enumerates 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.

THE REGRESSION. ctx.events.reconcile() and an action's reconcile() appear
nowhere in the 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: the kit rates reconcile the one
omission that is merely a lower standard rather than a broken promise, but that
assumes a world which persists. Rust wipes monthly and a wipe invalidates every
ledgered resource for that server at once, and core cannot tell a wedged sidecar
from a game that rebooted and lost everything - 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 trigger 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. Restored to phase 13.

R9 WAS MISPRICED. It says the map's per-layer switches work through the existing
visibility framework, which reads as reuse. It is not reuse: section 6.3 records
shardVisibility as 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. Section 2.7 forbids a
module requiring outside its own directory, so module-rust cannot import a line
of it and builds its own. Not a reason to change the decision; phase 14 now
carries a visibility layer of its own rather than a configuration of somebody
else's.

A MISSING DECLARATION. R13 claims two extension slots and never says where they
are declared. module.json has an extensions array and the dry run's own manifest
carried it. Like mounts it is a statement of surface the loader holds against
reality, so phase 1 adds it.

THE CTX MEMBERS. ctx has 29 members and the plan named a handful. The ones that
change work: secretBox, because each configured server's sidecar token is a
secret at rest and core already has the posture for it; middleware.rateLimit,
which is the mechanism R1 asked for without naming; uploads, which is where R9's
map image actually lands - the plan described fetching it and never said where
it goes; activity.log, which both R2 and R18 owe an audit trail to; the three
ctx.teams calls, since Teams is more than the provider; ctx.posts behind the
announce leg and post hook; and the three send paths section 10's catalogue
implies.

Also records section 6.8, a constraint on phase 10 and on purge: a trigger, a
rule and an audience outlive the module that declared them.
engagement_rules.trigger_id is a plain VARCHAR with no foreign key and no
cascade, so a module can be removed and reinstalled without destroying an
operator's rules - and a rule whose trigger or audience is unregistered shows
DORMANT, never an error and never auto-deleted. An audience resolving to the
empty set 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.

And the two client lists in full, since section 7 said UI kit discipline without
saying what is in it; plus one confirmation for R10, that
/api/v1/public/modules returns only started modules with no state and no failure
reason, so a Rust module that failed to boot makes the app render a site without
those screens rather than one advertising screens that 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 12:46:36 -05:00
parent 5bc038adf8
commit dbc0455490

View File

@@ -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