docs(modules): R7-R14 close the audit, and the phase list roughly doubles

Every question the contract audit raised was answered the same day. Eight new
decisions of record, and the schedule goes from twelve phases to twenty.

R7 the notifications and engagement set ships in v1 - streams, triggers,
audiences, seeds, announce leg, post hook - as ONE phase because they are a
matched set in the template rather than three independent gaps. The three rules
it lives on: ceiling is required and is not a ladder (a staff ceiling does not
permit owner, because one person for a cheat-detection event is the player it
was detected on); subjectKey must name a declared variable or every subject
shares undefined; and a rule group is offered once, so a rule appended to an
existing group reaches fresh installs only.

R8 multi-server from the start - the dry run's finding 2 taken at face value.
R12 per-wipe detail plus all-time rollups, with the truncation as a runtime
route and never a schema statement, since the fragment replays every boot.
R14 /rust on all three tiers, chosen deliberately because prefixes share one
namespace with core's and the collision probe cannot see core's root-mounted
endpoints. R13 two extension slots. R11 a small read-only slash command set,
with ephemerality fixed at the deferral so a refusal must defer ephemeral.

R9 is the one that split in two. The map IMAGE is static content on the game
host regenerated only on a wipe - the ch.3 2b case, so request/reply, one in
flight, two stages, its own derivation version, and no import on boot - while
everything moving on it is live state down the ordinary read path. Every layer
is an operator switch, and that is a security boundary rather than a preference:
public player positions in Rust locate players and let anyone infer base
positions. Default is monuments and world events public, players and bases
admin-only. This is the ONLY asset-bridge work in scope; item icons and the
2,590 skin ids stay out of v1.

R10 the Android app is in this workstream, deciding its screens from
/api/v1/public/modules capabilities, with each leg trailing the website surface
it consumes by one phase. Records the two endpoints that are not this and are
easy to confuse with it: /api/v1/public/status is site mode plus a version
block, and /api/health is an internal liveness probe.

Section 7 now carries the phase each previously-unplanned element lands in, and
the honest cost: twelve phases, from reading the chapters that describe the game
bridge and treating the module as the thin part when the kit says in its first
paragraph that the module is most of the work.
This commit is contained in:
2026-09-15 12:01:44 -05:00
parent a4544ba5f2
commit 5dcb8088ad

View File

@@ -1,7 +1,8 @@
# `module-rust` — the plan # `module-rust` — the plan
**Status:** approved in outline 2026-09-15, not started. Six decisions of record settled; two **Status:** approved in outline 2026-09-15, not started. **Fourteen decisions of record**; two
questions open (§3). questions deferred by choice (§3). Audited against the whole contract, not just the game-facing
chapters (§7).
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
@@ -98,7 +99,7 @@ teardown. This is neither: it is *continuously reconciled state where the websit
and its nearest relative in the contract is the Team provider **inverted** — instead of core asking and its nearest relative in the contract is the Team provider **inverted** — instead of core asking
the module what the game knows, the module tells the game what the site knows. The mechanism it the module what the game knows, the module tells the game what the site knows. The mechanism it
borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on
connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 10, connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 19,
after it has been built once. after it has been built once.
### R3 — the Kits reward action registers always and refuses with a reason ### R3 — the Kits reward action registers always and refuses with a reason
@@ -148,7 +149,7 @@ same declaration under whatever name the variant gives it, and the refusal is th
**Decided 2026-09-15 (org lead).** Basic functionality is built on **Rust's own clan system**; the **Decided 2026-09-15 (org lead).** Basic functionality is built on **Rust's own clan system**; the
third-party **uMod Clans plugin** is an optional layer for a much richer experience, and lands in third-party **uMod Clans plugin** is an optional layer for a much richer experience, and lands in
phase 9 rather than phase 6. phase 17 rather than phase 9.
The distinction matters more than the names suggest, so it is worth stating precisely: The distinction matters more than the names suggest, so it is worth stating precisely:
@@ -179,13 +180,13 @@ first-party clans answer all three. Feeding the Team provider from first-party c
**permanent, not a first step**. **permanent, not a first step**.
What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in What the plugin genuinely adds is **alliances and clan/alliance chat** — richer in *features*, not in
roster data. That is what phase 9's adapter surfaces, and it sits beside the Team provider rather roster data. That is what phase 17's adapter surfaces, and it sits beside the Team provider rather
than under it. than under it.
One route is deliberately not taken: the plugin persists to its own files under `oxide/data/`, and a One route is deliberately not taken: the plugin persists to its own files under `oxide/data/`, and a
determined integration could read those directly. **That is reaching into another plugin's private determined integration could read those directly. **That is reaching into another plugin's private
storage, not using an API** — it breaks without warning on any upstream refactor and is not a storage, not using an API** — it breaks without warning on any upstream refactor and is not a
contract anybody owes us. If phase 9 wants roster data from the plugin, the honest path is an contract anybody owes us. If phase 17 wants roster data from the plugin, the honest path is an
upstream pull request adding a read method, not a file reader. upstream pull request adding a read method, not a file reader.
### R6 — the required base set is Kits, Clans and PopupNotifications, all k1lly0u ### R6 — the required base set is Kits, Clans and PopupNotifications, all k1lly0u
@@ -218,7 +219,7 @@ including the three things the event work actually needs:
It also **raises `OnKitRedeemed(BasePlayer player, string kitName)`**, which the bridge can listen on It also **raises `OnKitRedeemed(BasePlayer player, string kitName)`**, which the bridge can listen on
to report a redemption as an ordinary event, whoever triggered it. to report a redemption as an ordinary event, whoever triggered it.
**Two traps in `GiveKit` that phase 8 must handle, both found by reading it rather than by reasoning **Two traps in `GiveKit` that phase 13 must handle, both found by reading it rather than by reasoning
about it.** about it.**
**`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The **`GiveKit` returns `null` on a failure path, and `null` is Oxide's idiom for "no opinion".** The
@@ -231,11 +232,11 @@ a success.**
**`GiveKit` takes a `BasePlayer`, so the player must be connected.** There is no offline grant in **`GiveKit` takes a `BasePlayer`, so the player must be connected.** There is no offline grant in
this API. An event that rewards participants at two in the morning rewards only whoever is online at this API. An event that rewards participants at two in the morning rewards only whoever is online at
that moment, silently. Phase 8 has to choose: accept online-only and say so in the action's that moment, silently. Phase 13 has to choose: accept online-only and say so in the action's
description, or keep our own persisted pending-grant queue in the bridge and redeem it on next description, or keep our own persisted pending-grant queue in the bridge and redeem it on next
connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store connect. **The queue is the honest answer and it is not free** — it is a second at-most-once store
with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save. with its own idempotency, which is exactly the machinery chapter 4 says to persist in the world save.
Decide it deliberately at phase 8 rather than discovering it from a complaint. Decide it deliberately at phase 13 rather than discovering it from a complaint.
`PopupNotifications` is small and does exactly one thing: `CreatePopupNotification(string message, `PopupNotifications` is small and does exactly one thing: `CreatePopupNotification(string message,
BasePlayer player = null, float duration = 0f)`, where a null player makes it global. That is the BasePlayer player = null, float duration = 0f)`, where a null player makes it global. That is the
@@ -244,23 +245,139 @@ server-side notification surface, and it needs no more than that.
**The one gap to design around:** the seven first-party hooks carry created, disbanded, added, **The one gap to design around:** the seven first-party hooks carry created, disbanded, added,
kicked and left — but **no promote or leader-changed event**. Core's provider requires kicked and left — but **no promote or leader-changed event**. Core's provider requires
`getTeamLeaders`, so leadership is read off `LocalClan` at snapshot time rather than tracked from `getTeamLeaders`, so leadership is read off `LocalClan` at snapshot time rather than tracked from
transitions. That makes phase 6 *partly* snapshot-driven where the dry run predicted it would be transitions. That makes phase 9 *partly* snapshot-driven where the dry run predicted it would be
fully event-driven — a small correction to that document, recorded here rather than silently. fully event-driven — a small correction to that document, recorded here rather than silently.
### R7 — the notifications and engagement set ships in v1
**Decided 2026-09-15 (org lead).** `registerNotificationStreams`, `registerEventTriggers`,
`registerAudiences` and `registerEngagementSeeds` are all in the first release, plus
`registerAnnounceLeg` and `registerPostHook`.
They are a **matched set**, which is why they are one decision and one phase: a trigger declares the
payload contract and the widest audience a rule on it may ever be given, an audience resolves *people*
over module data, and seeds ship the bodies and the rules that use them. Three rules from the kit
that this phase lives or dies on:
- **`ceiling` is required, has no default, and is not a ladder.** A `staff` ceiling does **not** permit
`owner`, because "one person" for a cheat-detection event is *the player it was detected on*. Fewer
people is not less exposure.
- **`subjectKey` must name one of your declared variables** — it is what the cooldown keys on. Core
refuses the module at boot if it names nothing, which is the good failure; the bad one it prevents
is every subject sharing `undefined`.
- **A rule group is offered once, per group key.** A rule appended to an existing group reaches
**fresh installs only** — so a rule that must reach existing deployments takes a new group key.
And the emit discipline: **emit on the transition, not on the poll.** Core's cooldown would hide a
module that emitted "the server is still up" as news.
### R8 — multi-server from the start
**Decided 2026-09-15 (org lead).** The server list is the landing page and everything else hangs
under `/rust/servers/:id`. Every gameplay row carries a server id as well as a `wipe_id`, and the
module holds one sidecar client per configured server.
This is the dry run's finding 2 taken at face value: *"one module, one game" is not the same as "one
module, one server"*. Retrofitting an `:id` segment through every route, table and page is the
expensive version, and a Rust community runs several servers by default.
### R9 — the live map, with every layer toggleable
**Decided 2026-09-15 (org lead).** The site offers a live map, and **each layer is an operator switch
set to public / players-only / admin-only** through the existing visibility framework
([`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md)).
**The map is two problems and they take different paths, which is the thing to get right before
building either:**
- **The map image is static content on the game host** — `proceduralmap.<size>.<seed>.<save>.map`
beside the world save, regenerated only on a wipe. That is exactly [kit][kit] ch. 3 §2b's case, and
it takes that shape: **request/reply, never events** (a sidecar that broadcast it would write
megabytes into its own store and fan them at every client), **one in flight with an explicit busy**,
**two stages — what exists, then what changed**, and a **derivation version** separate from the
protocol so improving how we read the file invalidates a cached image whose source hash did not
move. And **no import on boot**: a wipe is an event the operator knows about and the website does
not.
- **Everything moving on it is live state** down the ordinary read path — monuments, cargo ship,
patrol helicopter, airdrops, locked crates, and player positions.
**The layer switches are a security boundary, not a preference.** Public player positions in Rust are
a competitive-advantage leak — anyone, including people who do not play on the server, could locate
players and infer base positions. The default posture is monuments and world events public, player
and base layers admin-only, and an operator opening one up is a deliberate act with the consequence
stated on the switch.
**This is the only asset-bridge work in scope.** Item icons and the 2,590 workshop skin ids stay out
of v1; kill feeds and kit lists render as text.
### R10 — the Android app is in this workstream, capability-driven, trailing by one phase
**Decided 2026-09-15 (org lead).** Full Rust support in the app, not a degradation check. It decides
which screens to show from **`GET /api/v1/public/modules`** — each started module's `capabilities`
array — and each app leg lands **one phase after** the website surface it consumes is merged, so it
is always built against a real endpoint rather than a planned one.
Two endpoints that are *not* this and are easy to confuse with it, both checked on 2026-09-15:
`/api/v1/public/status` returns site mode plus a version block — the first-run probe and
version-mismatch guard, not a feature manifest — and `/api/health` on the internal app is a liveness
probe returning `{status:'ok'}`. Neither can say which pages exist.
§2.9's rule governs: **treat an unknown capability as absent, and never infer a URL from one.**
### R11 — a small read-only set of Discord slash commands
**Decided 2026-09-15 (org lead).** Questions answered from data the module already holds — server
status, wipe schedule, leaderboards, who is online. No write verbs, and the account link stays on the
two surfaces R1 names rather than acquiring a third.
The trap to carry in from the Teams work: **ephemerality is fixed at the deferral**, so a command
that might refuse must defer ephemeral or its refusal goes public in the channel. And the registries
have no removal path, which is an argument for adding a command late rather than early.
### R12 — per-wipe detail plus all-time rollups
**Decided 2026-09-15 (org lead).** Every gameplay row carries `wipe_id`; leaderboards default to the
current wipe; a separate rollup accumulates per player across wipes so a returning player's history
survives the monthly reset.
The truncation is a **runtime operation on a module route, never a schema one** — §2.6's leading-verb
allowlist forbids `DELETE` and `TRUNCATE` in a fragment precisely because the fragment replays at
every boot and would empty the table on each restart.
### R13 — two extension slots: `admin.users.detail` and `site.footer.status`
**Decided 2026-09-15 (org lead).** An operator looking at a user sees their linked Steam identity,
per-server stats and site-authored permission grants in core's own admin user page; the footer
carries a shard-status indicator on every page.
One module per slot, so claiming them also reserves them. And the naming rule matters for anyone
reading this later: **a slot is named for a PLACE, never for a meaning**`site.footer.status` is
"the status-ish spot in the footer", not core knowing what a game server is.
### R14 — `/rust` on all three tiers
**Decided 2026-09-15 (org lead).** `public`, `admin` and `player` all mount `/rust`, exactly as
`module-uo` mounts `/uo`. Sub-surfaces are path segments: `/rust/servers/:id`, `/rust/map`,
`/rust/clans`.
Chosen deliberately because **prefixes share one namespace with core's own and the loader's collision
probe cannot see all of core's** — several core endpoints are mounted at the tier root rather than
under a prefix. A noun from our own domain that equals the module id cannot collide, where
`/servers` or `/map` very well might.
## 3. Open questions ## 3. Open questions
The base set question is closed by R6. Two smaller ones are open, neither blocking: The fourteen decisions above close every question the contract audit raised. Two remain, both
deliberately deferred rather than unanswered:
**Clans is in the base set *and* the Team provider reads first-party clans.** Those are not in **Clans is in the base set *and* the Team provider reads first-party clans.** Not in conflict — the
conflict and this plan reads them as complementary: the plugin is installed because a community plugin is installed because a community wants alliances and clan chat, while core's Teams are fed
wants alliances and clan chat, while core's Teams are fed from the first-party system that actually from the first-party system that actually publishes membership transitions (R5). **Still an
publishes membership transitions (R5). Phase 9's adapter surfaces the plugin's extra features beside interpretation rather than something stated.**
the provider rather than under it. **Worth confirming** — it is an interpretation, not something that
was stated.
**Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether phase 8 accepts **Offline reward grants.** `GiveKit` requires a connected `BasePlayer` (R6). Whether the reward phase
online-only or builds a persisted pending-grant queue is a real decision with real cost, and it is accepts online-only or builds a persisted pending-grant queue is a real decision with real cost, and
deliberately deferred to that phase rather than guessed now. it belongs in that phase with the code in front of it.
`D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0. `D:\rust\oxide\plugins\` is **empty**, so the whole base set is a fresh install in phase 0.
@@ -290,28 +407,43 @@ re-checks it.
## 5. The phases ## 5. The phases
Phases 03 produce a working read-only Rust site. 45 are the permissions product. 6 is Teams. 78 **Twenty phases, roughly doubled from the first draft** — the contract audit in §7 is why, and the
are events. 9 is the optional tier. 10 is how any of it reaches somebody who is not us. Each phase honest reading is that the first list was a game-bridge plan with a website module bolted on, where
ends with its findings written down, as every workstream here does. the kit treats the module as the bulk of the work.
04 produce a working read-only multi-server Rust site that an operator can actually install. 67 are
the permissions product. 9 is Teams. 10 is the notifications set. 1213 are events. 14 is the map. 18
is how any of it reaches somebody who is not us. The Android legs (5, 8, 11, 15) each trail the
website surface they consume by one phase, per R10.
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, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something | | 0 | **The rig.** Update to the current wipe (the script is fixed), confirm the Oxide build still matches, install the base set (R6), prove a console grant reaches a plugin | docs | A current server boots with Kits, Clans and PopupNotifications loaded and `oxide.grant` demonstrably gates something |
| 1 | **Protocol 1 and three skeletons.** Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from `template/` | all 3 + docs | One hello line travels game sidecar module, and killing the sidecar does not stall the game | | 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 |
| 2 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots kept distinct at the wire; `wipe_id` everywhere; every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation | | 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 first pages.** Server list, per-server status, killfeed, leaderboard; `capabilities`; nav | Module-rust | The site renders the last thing the game said while the game is off | | 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 | **Identity** (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID | | 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 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe | | 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 |
| 6 | **Teams provider** (R5). Rust's first-party clans; membership event-driven, leadership read off `LocalClan` at snapshot | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
| 7 | **Events: one budget, one lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-rust + 2 | The leased value is observed changing in the running game and restored, per key | | 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
| 8 | **Events: the Kits reward action** (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree | | 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
| 9 | **Optional mod integrations**, the uMod **Clans** plugin first (R5). One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module, Teams included | | 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied | | 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook | Module-Rust + docs | An operator turns on a rule, edits a body, and a wipe announcement reaches the right people and nobody else |
| 11 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter answered either way | | 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: one budget, one verified lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-Rust + 2 | The leased value is observed changing in the running game and restored, per key |
| 13 | **Events: the Kits reward action** (R3, R6). Includes the offline-grant decision (§3) | all 3 | A retried step grants loot once, and the ledger and the world agree |
| 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 |
| 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.** The uMod **Clans** adapter first — alliances and clan chat, beside the provider rather than under it (R5) — then others, each detecting via `[PluginReference]` and degrading to absent | Rust-Plugins + docs | A server missing every optional mod still runs the module, Teams included |
| 18 | **The installer** (R4). `--game servuo|rust`, the bundle payload as a variant, an Oxide prerequisite check in `doctor`, the protocol pairing refusal carried over | installer + docs | An operator sets a Rust server up with the released binary and nothing hand-copied |
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change); and the Integration-kit question R2 raised | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
### Why the lease comes before the reward action ### Why the lease comes before the reward action
The stated priority is Kits rewards, and this plan still schedules a lease first. [kit][kit] ch. 5 is The stated priority is Kits rewards, and this plan still schedules a lease first (12 before 13). [kit][kit] ch. 5 is
explicit about it and the reasoning survives restating: a lease is *a value that already existed, explicit about it and the reasoning survives restating: a lease is *a value that already existed,
changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action
makes something that did not exist. The lease proves the whole command path — correlation, the makes something that did not exist. The lease proves the whole command path — correlation, the
@@ -321,7 +453,7 @@ failures.
Reversible on request. Reversible on request.
### What phase 8 must declare honestly ### What phase 13 must declare honestly
**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and **A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and
have the thing undone, on every terminal path including an abort — and there is no honest way to have the thing undone, on every terminal path including an abort — and there is no honest way to
@@ -355,93 +487,89 @@ is invalidated at once, and **"gone, and that is fine" is a success**, not a fai
## 7. Contract coverage audit ## 7. Contract coverage audit
Added 2026-09-15 after re-reading the whole kit rather than only chapters 35. **The first draft of Added 2026-09-15 after re-reading the whole kit rather than only chapters 3-5. **The first draft of
§5 was built from the game-facing chapters and under-planned the website module by a wide margin.** §5 was built from the game-facing chapters and under-planned the website module by a wide margin**
Every element of the contract is listed below with where it lands; the unplanned ones are the point the template makes eight of the ten non-event registrations and the plan covered three. Every element
of the table. is listed with the phase it now lands in; the column worth reading is *"was it in the first draft"*,
because that is the shape of the mistake.
### The server handshake — ten registrations plus two hooks (ch. 2) ### The server handshake — ten registrations plus two hooks (ch. 2)
| Call | Planned? | Where | | Call | In the first draft | Phase |
|---|---|---| |---|---|---|
| `registerRoutes` | yes | 3 | | `registerRoutes` | yes | 4 |
| `registerTeamProvider` | yes | 6 | | `registerTeamProvider` | yes | 9 |
| `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 78 | | `registerEventBudgets` / `OptionSources` / `Leases` / `Actions` | yes | 12-13 |
| `onBoot` / `onShutdown` | **implicit only** | make explicit in 1 | | `onBoot` / `onShutdown` | implicit only | 1 |
| `registerExtension` | **no** | the dry run's own manifest declared `admin.users.detail` | | `registerExtension` | **no** | 4 (`site.footer.status`), 6 (`admin.users.detail`) |
| `registerNotificationStreams` | **no** | | | `registerNotificationStreams` | **no** | 10 |
| `registerEventTriggers` | **no** | | | `registerEventTriggers` | **no** | 10 |
| `registerAudiences` | **no** | | | `registerAudiences` | **no** | 10 |
| `registerEngagementSeeds` | **no** | | | `registerEngagementSeeds` | **no** | 10 |
| `registerAnnounceLeg` | **no** | | | `registerAnnounceLeg` | **no** | 10 |
| `registerPostHook` | **no** | | | `registerPostHook` | **no** | 10 |
| `registerSlashCommands` | **no** | | | `registerSlashCommands` | **no** | 16 |
**The template registers eight of these and the plan covered three.** Triggers, audiences and Triggers, audiences and seeds are one phase because they are a **matched set** in
engagement seeds are in `template/server/index.js` as a matched set — a trigger declares the payload `template/server/index.js`, not three independent gaps — see R7.
and its audience `ceiling`, an audience resolves people over module data, and seeds ship the bodies
and rules. None of the three was in §5.
### The bundle's own parts ### The bundle's own parts
| Part | Planned? | Note | | Part | In the first draft | Phase |
|---|---|---| |---|---|---|
| `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 3 | | `module.json` `id` / `coreApi` / `capabilities` | yes | 1, 4 |
| **`mounts` and prefix choice** | **no** | shares one namespace with core's, and the loader's probe cannot see core's root-mounted endpoints | | `mounts` and prefix choice | **no** | 1 (R14) |
| `schema.sql` | yes | 2 | | `schema.sql` | yes | 1, 3 |
| **`purge.sql`** | **no** | required whenever a schema ships | | `purge.sql` | **no** | 1 |
| **`swagger-fragment.json` + generator + `check:swagger`** | **no** | fixed filename, merged verbatim, must be committed and checked current | | `swagger-fragment.json` + generator + `check:swagger` | **no** | 1 |
| **`vite.config.js` aliases / shims / `checkExternals`** | **no** | the kit calls this the highest-risk mechanical detail in the system | | `vite.config.js` aliases / shims / `checkExternals` | **no** | 1 |
| **`checkImports.js`** | **no** | the guard that makes a reach into core's tree fail the build | | `checkImports.js` | **no** | 1 |
| **`release.yml`, install manifest, `sha256`, host allowlist** | **no** | there was no packaging phase at all | | `release.yml`, install manifest, `sha256`, host allowlist | **no** | **2** there was no packaging phase at all |
Phase 1 carries most of these on purpose. [kit][kit] ch. 1's whole argument is to get every seam
working at once with almost nothing in them, so that afterwards you break exactly one at a time.
### The client half ### The client half
| Part | Planned? | Note | | Part | In the first draft | Phase |
|---|---|---| |---|---|---|
| `registry.registerRoutes` / `registerNav` | yes | 3 | | `registry.registerRoutes` / `registerNav` | yes | 4 |
| **`declareModuleSlot`** | **no** | the kit: *"you will need it the moment your game has anything like a guild"* — so phase 6 | | `declareModuleSlot` | **no** | 9 — the kit: *"you will need it the moment your game has anything like a guild"* |
| `registerFeatureProvider` | **no** | how a nav row becomes conditional | | `registerFeatureProvider` | **no** | 4 |
| **UI kit discipline** | **no** | `PublicLayout` needs `shell`; `PageHeader` takes `eyebrow`/`title`/`lead`/`center` and drops unknown props silently | | UI kit discipline (`PublicLayout` `shell`, `PageHeader` props) | **no** | 4 |
### Beyond the module ### Beyond the module
| Item | Planned? | Note | | Item | In the first draft | Phase |
|---|---|---| |---|---|---|
| Sidecar rpc correlation | implicit | needed by 78; state it in 1 | | Sidecar rpc correlation | implicit | 1, stated |
| **Asset bridge (ch. 3 §2b)** | **no** | Rust's item icons and 2,590 skin ids are exactly the "content already on the game host" case | | Asset bridge (ch. 3 §2b) | **no** | 14 — **map image only** (R9) |
| **`.profile` landing page** | **no** | three repos added is precisely when it is owed | | `.profile` landing page | **no** | 19 |
| **`runicgateway.com`** | **no** | a second game is a headline platform change | | `runicgateway.com` | **no** | 19 |
| **Android app** | **no** | it feature-detects `capabilities` and must render a site whose module it has never heard of | | Android app | **no** | 5, 8, 11, 15 (R10) |
| `docs/` | yes | 11 | | `docs/` | yes | every phase, plus 19 |
### What this does to the phase list **What the audit cost the schedule: twelve phases.** That is the honest number, and it is worth
recording because the under-planning had one cause — reading the chapters that describe the game
bridge and treating the module as the thin part, when the kit says in its first paragraph that the
website module is most of the work.
The gaps are not evenly distributed: most land in the website module, which the original §5 treated ## 8. Questions the audit raised — all answered
as three phases and which the kit treats as the bulk of the work. The phase list needs a packaging
phase, a notifications-and-engagement phase, and explicit bundle-mechanics work inside phase 1 —
plus decisions on the optional surfaces (slash commands, extension slots, the asset bridge) before
they can be scheduled. Those decisions are §8.
## 8. Questions this plan cannot answer for itself Every question §7 produced was put to the org lead on 2026-09-15 and answered the same day. They are
recorded as R7R14 in §2 rather than repeated here:
Open, in rough order of how much they change the schedule. None is blocking phase 0. | Question | Answer | Decision |
|---|---|---|
| Notifications and engagement in v1? | the full matched set | R7 |
| How many servers does the UI support? | multi-server from the start | R8 |
| The asset bridge? | **only** the live map — not item icons, not skins | R9 |
| Android in this workstream? | full app, capability-driven, trailing by one phase | R10 |
| Discord slash commands? | a small read-only set | R11 |
| What survives a wipe? | per-wipe detail plus all-time rollups | R12 |
| Extension slots? | `admin.users.detail` **and** `site.footer.status` | R13 |
| Which mount prefixes? | `/rust` on all three tiers | R14 |
1. **Notifications and engagement** — does v1 ship triggers, audiences and engagement seeds, or is The two in §3 are deferred by choice rather than unanswered.
that a later workstream? The template treats them as core furniture; skipping them means a Rust
site that cannot mail anybody about anything.
2. **Discord slash commands** — the platform has a bot and `registerSlashCommands` exists. Does the
Rust module claim any?
3. **Extension slots** — the dry run's manifest declared `admin.users.detail` (a user's Rust
identities inside core's admin user page). Keep it?
4. **The asset bridge** — item icons and skins over the bridge, or not in v1? It is a whole
protocol's worth of work and the site renders without it.
5. **Android** — does the app get Rust support in this workstream, or does it stay UO-only until the
module ships?
6. **Which mount prefixes**, given they share a namespace with core's own.
7. **Server scale** — how many Rust servers should the first release support in the UI? The dry run
says the module holds one client per server; the pages differ a lot between one and six.
8. **Wipe retention** — when a wipe truncates gameplay data, what survives? All-time rollups, or a
clean slate each month?
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit [kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit