`link/v7.md` gains §11-§14: the two targeted lease planes, the two one-shots, the
routes, and what the build found in already-merged code. `EVENTS.md` §G's five
part-b rows are marked built, three of them carrying a correction. `MODULE_API.md`
records the three contract members 12b amends into 1.10.0. `EVENTS_PLAN.md` has
Phase 12b as built.
THE DESIGN POINT, WRITTEN DOWN
A borrowed value whose home is the world save does not come back on a restart the
way a config value does. 11b's fail-safe -- a lease that never reaches disk makes
a restart a free restore -- depends entirely on the leased value being
memory-only too, and for a spawner property or a seasonal status it is not: a
restart preserves the CHANGE and destroys only the timer that would have undone
it. So those two planes' holds are persisted and their deadlines re-armed, and
the config plane's still are not. The same argument, applied where its premise is
false.
FIVE CORRECTIONS TO EVENTS.md
- `Spawner.Amount` does not exist. The property is `MaxCount`, and
`MinDelay`/`MaxDelay` are TimeSpans, so the wire carries seconds.
- The seasonal toggle is not "small and safe". Safe, yes -- ServUO does it to
itself from a staff gump -- but `OnStatusChange()` generates or removes world
content for six of the eight permitted types.
- It is a THREE-value enum over nine named events, not a nine-value enum.
(Caught in 12a's survey; the row is corrected here now it is built.)
- `TreasuresOfTokuno` is excluded, because `IsActive()` reads its own `DropEra`
rather than `Status`. A lease on it applies cleanly, reads back, restores
cleanly and changes nothing -- §N10's "capability that lies", and the one
instance no runtime probe can catch.
- The grant row said failure aborts rather than retries. Protocol 6 changed that:
an idempotency key means a repeat is answered by the original reply, so a
retried grant cannot be one winner receiving two.
And the config-lease row is closed at one key. Counted on ServUO 57.4: 156
non-Bridge `Config.Get` call sites, 82 outside a field declaration, all but four
of those inside a `Configure()` or static constructor and cached at boot anyway.
The self-check ships regardless -- it exists for the operator whose OWN scripts
read config live.
MODULE_API 1.10.0, AMENDED IN PLACE
`target` on a lease declaration, `values` on a string lease, and `searchable` +
`{ q }` on option sources. Amended rather than bumped for the reason every phase
since P10 has: 1.10.0 has never reached `main`, so no deployment can tell the
difference, and the cutover is what publishes the whole of it.
The `target` entry records why this extends `core.lease` rather than giving the
module a lease verb of its own: §F settled that in Phase 8, and half its
objection no longer holds (the two-events-one-target refusal comes from the
ledger's unique index whichever verb reserves the row) while the other half --
`maxDurationMs` re-implemented per module -- still does.
§14 records the protocol-pin defect 11a and 12a both shipped, and why the test
that guards it passed anyway: it asserts the three declarations agree with each
other, which all three being equally stale satisfies.
CHECKS
`docs` has no CI. Every relative link in the new sections was resolved by hand;
the files are CRLF in the working tree and each diff is content-sized
(`--numstat` matches the real change), so nothing carries the `\r\r\n` full-file
rewrite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2529 lines
166 KiB
Markdown
2529 lines
166 KiB
Markdown
# The Module API — the contract
|
|
|
|
**Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md), **validated by the atlas
|
|
spike** — Part 7 records what the spike proved, what it changed in this contract, and the three
|
|
artifacts in it that are not design. This document is the
|
|
normative contract between the core website and an installed module. `MODULE_SYSTEM.md` decides
|
|
*what* the module system is; this decides *exactly what a module may call, what it must provide, and
|
|
what core promises not to break*.
|
|
|
|
Everything below is derived from what the UO code actually does today, re-read against the working
|
|
tree on 2026-08-10. Where the survey contradicted `MODULE_SYSTEM.md`, the contradiction is recorded
|
|
in Part 6 rather than quietly resolved — four of them, one of which (OpenAPI, §6.1) needs a decision
|
|
before Phase 2 starts.
|
|
|
|
**The one rule everything else serves:** a module reaches core *only* through the members named in
|
|
this document. Zero `require`/`import` from a module to a core file, enforced in CI (§5.1). A core
|
|
refactor that leaves this contract intact cannot break a module; anything a module needs that is not
|
|
here extends the contract first, in this file, before the module is written against it.
|
|
|
|
---
|
|
|
|
## Part 1 — Versioning
|
|
|
|
### 1.1 `MODULE_API_VERSION`
|
|
|
|
Core exports a single integer-major semver string from `server/src/modules/version.js`:
|
|
|
|
```js
|
|
const MODULE_API_VERSION = '1.10.0'
|
|
```
|
|
|
|
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
|
|
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
|
module chunk evaluates, which is earlier than any network round trip could answer.
|
|
|
|
**1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
|
|
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
|
|
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8).
|
|
Four additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
|
|
deployment simply has fewer verbs an event can use — which is §F's own posture stated as a version
|
|
rule, because core with none of this installed is still an event engine that can announce, wait, cue
|
|
a human and publish results.
|
|
|
|
> **Phase 8 added `reconcile()` and `ctx.events.reconcile()` to this same version rather than to a
|
|
> new one** (org lead, 2026-09-03). A protocol owes a bump once it has landed on `main`; while it is
|
|
> on `edge` it is amended in place — the rule the Teams workstream arrived at, applied to a module
|
|
> API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as
|
|
> one version they read once, which was the argument for putting the lease declaration here in the
|
|
> first place.
|
|
>
|
|
> **Phase 10 amended it a second time, under the same rule**, with `participants` on the success
|
|
> envelope and an optional narrowing `ceiling` on `ctx.events.emit`'s. `main` still declares 1.9.0,
|
|
> so 1.10.0 remains unshipped and the whole event contract — actions, budgets, leases, option
|
|
> sources, reconcile, participants — still reaches an author as one number. The `ceiling` member is
|
|
> the one of the two that widens something 1.9.0 already shipped, and it is additive and optional:
|
|
> a module that never passes it is emitting exactly what it emitted before.
|
|
|
|
**Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`,
|
|
`core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in —
|
|
`loader.js` built its own `api` facade and had no method that delegated to it. So the seam a module
|
|
now reaches is one that has been exercised on every boot for six phases, rather than one whose first
|
|
registrant is a stranger. That is the same argument `registerCore()` has made since the module
|
|
system's Phase 3, and 1.10.0 is when it pays.
|
|
|
|
```js
|
|
api.registerEventBudgets([
|
|
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' }, // a DIMENSION core can bound
|
|
])
|
|
|
|
api.registerEventActions([{
|
|
id: 'uo.creature.spawn', // <moduleId>.-prefixed; its OWN id space
|
|
label: 'Spawn creatures',
|
|
risk: 'change', // closed: notify | inspect | change | irreversible
|
|
reversible: 'ledger', // closed: none | self | ledger | override
|
|
version: 1,
|
|
budgetMs: 10000,
|
|
cost: (p) => ({ 'uo.creatures': p.count }), // what ONE invocation consumes
|
|
params: [
|
|
{ name: 'creature', type: 'string', required: true,
|
|
example: 'Orc', source: 'uo.options.creatures' }, // a `source` makes it a dropdown
|
|
{ name: 'count', type: 'int', required: true, example: 12 },
|
|
],
|
|
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {
|
|
if (verify) return { ok: true } // dry run: validate, change NOTHING
|
|
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
|
|
},
|
|
// Required iff reversible: 'ledger'. Called by core's cleanup sweep at teardown,
|
|
// over the rows this action's `resources` produced — a LIST, so twelve creatures
|
|
// are one round trip. `{ ok: true }` reverts the group; `failed: ['0x...']` names
|
|
// the ones that did not come back.
|
|
async revert({ runId, resources, idempotencyKey }) { return { ok: true } },
|
|
// OPTIONAL, and only on an action that ledgers. "Which of these does the game
|
|
// still have?" — asked after something outside core restarted.
|
|
async reconcile({ runId, resources }) { return { ok: true, inForce: ['0x40001234'] } },
|
|
}])
|
|
|
|
// The module says WHEN, because core cannot: core has no concept of the game
|
|
// being up. module-uo already watches `bootId` to tell a shard restart from a
|
|
// sidecar reconnect, and that is the moment a ledger of live spawns has become a
|
|
// claim about a world that no longer exists.
|
|
ctx.events.reconcile()
|
|
|
|
api.registerEventOptionSources([{
|
|
id: 'uo.options.creatures', label: 'Creatures',
|
|
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
|
|
}, {
|
|
// A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and
|
|
// requires it of none, so a resolver that ignores it is unchanged; `searchable`
|
|
// is what tells the authoring form to render a typeahead rather than a select.
|
|
id: 'uo.options.spawners', label: 'Spawners', searchable: true,
|
|
async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) },
|
|
}])
|
|
|
|
// A value a run may borrow. The module ships the three callables; the VERB an
|
|
// author puts in a step is core's `core.lease`, so the duration bound and the
|
|
// two-events-one-target conflict check live in one place.
|
|
api.registerEventLeases([{
|
|
id: 'uo.rate.skillgain', label: 'Skill gain rate',
|
|
type: 'float', min: 0.5, max: 5, maxDurationMs: 86400000,
|
|
async read() { return { ok: true, value: 1.0 } },
|
|
async apply(v, until) { return { ok: true } },
|
|
async restore(baseline, { expected }) { return { ok: true } },
|
|
async inForce() { return { ok: true, held: true } }, // optional
|
|
}, {
|
|
// A TARGETED lease: one capability over many things. Core adds the target to
|
|
// the reservation ref (`<lease id>#<target>`) so two runs may hold the same key
|
|
// on two different objects, and hands it to all four callables.
|
|
id: 'uo.spawner.maxcount', label: 'Spawner: how many at once',
|
|
type: 'int', min: 0, max: 100, maxDurationMs: 43200000,
|
|
target: { label: 'Which spawner', source: 'uo.options.spawners' },
|
|
async read({ target }) { return { ok: true, value: '3' } },
|
|
async apply(v, until, { target }) { return { ok: true } },
|
|
async restore(baseline, { expected, target }) { return { ok: true } },
|
|
async inForce({ target }) { return { ok: true, held: true } },
|
|
}, {
|
|
// A string lease may close its value set. `min`/`max` bound the numeric types
|
|
// and nothing bounded `string`, so without this the only check on the value is
|
|
// the game side's -- a refusal arriving unattended, mid-run, rather than on the
|
|
// authoring form.
|
|
id: 'uo.seasonal.status', label: 'Seasonal event status',
|
|
type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000,
|
|
target: { label: 'Which seasonal event', source: 'uo.options.seasonal' },
|
|
async read({ target }) { return { ok: true, value: 'Inactive' } },
|
|
async apply(v, until, { target }) { return { ok: true } },
|
|
async restore(baseline, { expected, target }) { return { ok: true } },
|
|
}])
|
|
```
|
|
|
|
**What a module author has to know beyond the four names**, because each is a rule rather than a
|
|
field:
|
|
|
|
- **No shape a failure can take reads as success.** A rejected promise, a throw, a `budgetMs`
|
|
timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. **A module that
|
|
needs the `retry: false` half of that to be reachable must declare a `budgetMs` longer than its
|
|
own transport's timeout** — see §2.4's rule, which exists because the first module to register an
|
|
action did not, and its one non-retryable verb was retried anyway. That is
|
|
`registerTeamProvider`'s default *inverted*, deliberately: a team provider that refuses leaves core
|
|
showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded
|
|
as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that
|
|
means "this will never work" says `retry: false`.
|
|
- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering
|
|
`{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything
|
|
else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone.
|
|
- **A lease that declares a `target` is a family of values, and core changes what it reserves.**
|
|
Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a
|
|
config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands
|
|
of spawners and reserving the id would let one run turning up one spawner refuse every other run
|
|
every other spawner. With one, the ref is `<lease id>#<target>`, the two-events-one-target index
|
|
bites at the granularity the world actually has, and the target reaches all four callables.
|
|
**Core refuses a targeted lease with no target and an untargeted one with a target**, both
|
|
`retry: false`: the second attempt has the same params. `target.source` names an option source
|
|
for the authoring form, and is not resolved by core at registration — a source registered by a
|
|
module that boots later must not make this one throw.
|
|
- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the
|
|
numeric types; a set on an int lease would be a second bound beside them with no rule about
|
|
which wins, so it is refused.
|
|
- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this
|
|
existed behaves identically. Declare `searchable: true` when the term actually narrows the
|
|
answer — the form reads that to decide between a typeahead and a select, and inferring it from a
|
|
truncated list would read correctly right up until a small deployment's list happened to fit.
|
|
Core needs it because a reconcile after an outage asks *"does the game side still have any record
|
|
of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run
|
|
applied is drift, which `restore()` reports so the row lands `drifted` with the current value
|
|
beside it, and a reconcile that inferred absence from a changed value would orphan the row first
|
|
and tell the operator the lease vanished rather than that somebody moved it. The two questions
|
|
have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's
|
|
step names `core.lease`, which is core's own action, so there is nowhere else a module could hang
|
|
the answer.
|
|
- **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module
|
|
registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because
|
|
the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same
|
|
as bounding it: a declared dimension with no operator cap is counted and unbounded.
|
|
- **`verify: true` must change nothing and must answer honestly.** It rides the same dispatcher a
|
|
real run uses, because a dry run down a second code path is a dry run of the second path.
|
|
- **`example` is required on every param, optional ones included** — the same rule
|
|
`registerEventTriggers` makes of a variable's example, for the same reason: it is the authoring
|
|
form's placeholder, it is one word at declaration time, and it is unreconstructable afterwards.
|
|
- **Actions, budgets, leases and option sources are four separate id spaces**, each namespaced
|
|
`<moduleId>.`. An action names a VERB, a budget a RESOURCE, a lease a VALUE and an option source a
|
|
CATALOG, so `uo.creatures` may legitimately appear in more than one — reading that as a collision
|
|
would forbid the most natural set of names a module will ever write.
|
|
- **A lease is declared by a module and acquired by CORE.** The verb is `core.lease`, and the
|
|
module never writes one: core reads the baseline, reserves the target in the resource ledger —
|
|
which is where "two events cannot hold one target" comes from, as a unique index rather than as a
|
|
check — applies the value with the deadline, and restores it at teardown through the module's own
|
|
`restore()`. A lease verb per module would be that bound re-implemented once per module, advisory
|
|
everywhere, and wrong in the first one that forgot it.
|
|
- **`until` goes down the wire, and the game side must honour it without being asked again.** A
|
|
module that treats it as advisory has produced a lease that outlives an outage, which is the one
|
|
thing a lease exists to prevent. Core's copy of the deadline is for the console; the game's copy is
|
|
the fail-safe.
|
|
- **`revert` must be idempotent, and reverting something that does not exist is a SUCCESS.** Core
|
|
records a resource BEFORE it is confirmed (`EVENTS.md` §D rule 1), so a dispatch whose answer was
|
|
lost leaves a row for something that may never have existed — and cleanup will ask about it. A
|
|
module never has to tell "I deleted it" from "it was not there". This is also what a Rust-style
|
|
monthly wipe needs, and the second reason a lease's restore must be idempotent too.
|
|
- **`revert` is also called with NO resources and only an idempotency key.** That is the lost-answer
|
|
case: core knows a dispatch went out under that key and never learned what it made. A module that
|
|
can undo by key answers honestly; one that cannot answers `{ ok: false }` and the row stays visible
|
|
to an operator, which is the correct outcome rather than a silent one.
|
|
- **`reconcile` is optional where `revert` is required**, and the asymmetry is the design. A module
|
|
that cannot say what the game still has is not broken — core keeps believing its own ledger, which
|
|
is the behaviour before this version — whereas one that created something and cannot undo it has
|
|
made a promise core has no way to keep. 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 a module
|
|
reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go.
|
|
|
|
**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
|
|
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
|
|
written against 1.8.0 keeps working and simply seeds nothing.
|
|
|
|
It exists because 1.7.0 let a module say what an event's payload *is* and gave it no way to say what
|
|
the message should *read* like. `engagement/templateSeeds.js` and `engagement/coreRules.js` are core
|
|
files with core arrays in them and there was no registration call beside them, so a module's
|
|
notification was core's generic `notify.event` body or nothing at all. That is tolerable for one
|
|
trigger and not for a catalogue: Phase 11 ships twenty-five, sixteen of which have domain prose that
|
|
core must never contain (§5.2 — and `check:modules` reads identifiers, never prose, so this boundary
|
|
is honoured deliberately rather than enforced mechanically).
|
|
|
|
```js
|
|
api.registerEngagementSeeds({
|
|
templates: [{
|
|
key: 'uo.house.idoc-warning', // MUST be namespaced "<moduleId>."
|
|
name: 'House — final decay warning',
|
|
channel: 'email', // 'email' | 'inapp'
|
|
subject: 'Thy house at {{region}} stands in peril',
|
|
triggerId: 'uo.house.idoc_warning',
|
|
triggerVersion: 2,
|
|
seedVersion: 1,
|
|
blocks: [ /* the same block objects the template editor writes */ ],
|
|
}],
|
|
ruleGroups: [{
|
|
key: 'triggers-v1', // the one-shot guard's name — see below
|
|
note: 'shown in the boot log when it inserts',
|
|
rules: [{
|
|
trigger_id: 'uo.house.idoc_warning', // MUST be one of this module's own
|
|
name: 'House decay warning',
|
|
audience: 'owner',
|
|
channels: ['email', 'inapp'],
|
|
template_keys: { email: 'uo.house.idoc-warning', inapp: 'uo.house.idoc-warning-inapp' },
|
|
cooldown_seconds: 86400,
|
|
max_sends_per_hour: 200,
|
|
delay_seconds: 900, // optional
|
|
cancel_on: ['uo.house.refreshed'], // optional
|
|
conditions: null, // optional
|
|
}],
|
|
}],
|
|
})
|
|
```
|
|
|
|
**Callable once per module, and validate-then-commit like every other registration.** A module that
|
|
got one of thirty templates wrong ships none of them and finds out at boot with the offending key
|
|
named, rather than at send time with a half-seeded table.
|
|
|
|
**The two halves behave differently, and the asymmetry is the contract.**
|
|
|
|
- **Templates are re-ensured on every boot.** Each row carries `seed_key`, `seed_version` and
|
|
`customized`, so re-ensuring is how a better default reaches a deployment *without* stealing an
|
|
operator's edit (§4.6.1 property 3 of `ENGAGEMENT.md`), and a template added in a later module
|
|
version reaches every deployment rather than only fresh ones. **Bump `seedVersion` when a body
|
|
changes; never for a comment.**
|
|
- **Rules are one-shot, per named GROUP.** Re-ensuring a rule would resurrect one an operator deleted
|
|
and reset one they enabled, so each group carries its own settings guard
|
|
(`engagement_module_rules_seeded:<owner>:<group>`). **A rule appended to an existing group therefore
|
|
reaches fresh installs ONLY** — never a deployment already stamped. A rule that must reach existing
|
|
deployments takes a **new group key**. The module names its groups, so the module makes that choice;
|
|
make it knowingly.
|
|
|
|
**Three things a module may not do, each of which is a shipped mistake that would only surface as
|
|
mail somebody received.**
|
|
|
|
1. **A seeded rule is always `enabled = 0`.** `enabled` is not a parameter — a value passed for it is
|
|
ignored rather than refused, because refusing would let a typo take a module offline at boot. This
|
|
is `ENGAGEMENT.md` Q3's invariant surviving the largest seed set in the workstream.
|
|
2. **A module may not mark a template `protected`.** That flag means *"the system breaks without this
|
|
body"*, which is true of a password reset and of nothing a module ships; setting it would take an
|
|
operator's delete button away.
|
|
3. **A rule may only name its own module's `trigger_id`**, and its `template_keys` may only name this
|
|
module's own seeds or **core's** (`notify.event`, `inapp.event`, `notify.digest` — which is
|
|
§4.6.1 property 1 in force, and the right answer for any trigger whose message is structural).
|
|
A template `key` must be namespaced `<moduleId>.`, because `engagement_templates.key` is UNIQUE
|
|
across the table and an unprefixed `notify.event` from a module would collide with core's and win
|
|
or lose on boot order.
|
|
|
|
**When it runs, which is not where the other seeders run.** `server.js` calls `seedDefaults()`
|
|
**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader —
|
|
so at the moment core seeds its own templates, no module has registered anything. Module seeds are
|
|
therefore written from `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and
|
|
**before** the `onBoot` dispatch. Two consequences worth relying on: a module the operator **disabled**
|
|
(or one that failed to load) is skipped rather than seeded, and a module that warms a cache in
|
|
`onBoot` may assume its bodies and rules already exist. Failure is logged and swallowed like every
|
|
other step there — a body that would not seed costs the shipped default, never the boot.
|
|
|
|
**It is not a send path.** Everything on the object is data; nothing on it is a function and nothing
|
|
on it names a recipient. A module still cannot mail anyone (§1.2): it declares who an event is
|
|
*about*, and core decides who is told, after the ceiling, the preferences, the suppression list and
|
|
the verification gate.
|
|
|
|
**1.8.0 — a seventh audience ceiling: `admin`** (`website/ENGAGEMENT.md` Phase 11, decision 1). One
|
|
addition and no removal, so minor; every declaration valid under 1.7.0 is valid now and no stored
|
|
value changes. `admin` is a **child of `staff`**, so a module may declare `ceiling: 'admin'` on a
|
|
trigger or an audience and a `staff`-ceilinged trigger accepts an `admin` audience as a narrowing.
|
|
|
|
It exists because the narrowest role-shaped value the lattice had was `staff`, which means **admin,
|
|
editor AND moderator**. Phase 11's operator-facing triggers — a digest of what staff did in game, the
|
|
economy thresholds, the world-save counts — are admin-audience everywhere they are described, and
|
|
ceilinging them at `staff` would have let an operator save a rule that mails the staff audit digest to
|
|
every moderator in it.
|
|
|
|
**What a module author has to know beyond the new name.** `admin` is the **only pair in the whole
|
|
lattice with real containment** — every admin is staff, which is exactly what every other pair of
|
|
branches lacks — so it is the only place `permits` is true between two values below `authenticated`.
|
|
`permits('staff', 'admin')` holds; `permits('admin', 'staff')` does not, and neither direction holds
|
|
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
|
|
unchanged, and so is every rule about composition narrowing rather than widening.
|
|
|
|
**1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed
|
|
signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions
|
|
until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`,
|
|
`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Almost nothing
|
|
was added to `ctx`: an action is called BY core, so what a module needs from this contract it is
|
|
handed in the envelope rather than reaching for — `ctx.events.reconcile()` (Phase 8) is the one
|
|
exception, because only the module knows when the game it talks to has restarted.
|
|
|
|
Two members joined it in Phase 10, both on an envelope:
|
|
|
|
- **`participants` on an action's SUCCESS envelope** (`EVENTS.md` §D, §J). An action may answer
|
|
`{ ok: true, participants: [{ memberKey, userId?, score?, meta?, joinedAt? }] }` and core records
|
|
them against the run, on both success shapes, beside `resources`. `memberKey` is required and
|
|
module-opaque; `userId` is optional and is the module's own answer to "is this player a website
|
|
account", because core cannot map one and a core that guessed would be one game's identity model
|
|
compiled into core. A bad entry is dropped and logged, never a retry: a retried step re-dispatches
|
|
a world write that already happened.
|
|
- **`ceiling` on `ctx.events.emit`'s envelope** (`EVENTS.md` §I). An optional audience ceiling for
|
|
THIS firing, which may only ever narrow: the send-time G24 gate applies `meet(declared, emitted)`,
|
|
so a rule wider than the meet is refused and one narrower is unaffected. Two incomparable ceilings
|
|
meet to null and every rule is refused, which is §5.1a rule 3's posture rather than a guess about
|
|
which branch was meant. The case that forced it is core's own — a rehearsal fires the same
|
|
lifecycle triggers as a real run and must not mail every subscriber — and it is on the shared
|
|
envelope rather than in `events/` because "this particular firing is narrower than the kind
|
|
usually is" is a fact any emitter can have.
|
|
|
|
Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended
|
|
into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there
|
|
is no deployment that could tell the difference, and the events cutover is what publishes the
|
|
whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose
|
|
and stays so until the cutover re-pins `ci/core-ref.json`.
|
|
|
|
- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a
|
|
FAMILY of values rather than a single value, and core reserves `<lease id>#<target>` rather than
|
|
the id — so two runs may hold the same key on two different objects while two runs holding one
|
|
object still collide on the unique index. The target reaches `read`, `apply`, `restore` and
|
|
`inForce`. Every lease before this named one value, so the id *was* the target and none of the
|
|
four needed an argument; a property does not have that shape.
|
|
|
|
**The verb stays core's**, which is the whole reason this is an extension rather than a lease
|
|
verb of the module's own. §F settled that in Phase 8: a lease verb per module would
|
|
re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and
|
|
wrong in the first one that forgot. Half of that objection no longer holds — the
|
|
two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the
|
|
row — and the other half still does.
|
|
- **`values` on a `string` lease.** The closed set an author may choose from, checked by
|
|
`core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded
|
|
`string`, so the only check on a string lease's value was the game side's — a refusal arriving
|
|
unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside
|
|
`min`/`max` would be a second bound with no rule about which wins.
|
|
- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose
|
|
catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the
|
|
argument answers exactly as it did before this existed, which is what makes it additive. The
|
|
first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the
|
|
2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says
|
|
nothing about which part. `searchable` is declared rather than inferred, because inferring it
|
|
from a truncated answer reads correctly right up until a small deployment's list happens to fit.
|
|
|
|
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
|
|
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
|
|
`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and
|
|
`api.registerEngagementSeeds({...})` (§2.4) ·
|
|
`ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3).
|
|
|
|
**This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked
|
|
— *a contract owes a bump only once it has landed on `main`* — was true when they were written and is
|
|
not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has
|
|
only ever been on `edge`" are **historical, not current**. Everything added from here takes a version
|
|
of its own. That is also why the integration kit does not go red until the engagement cutover:
|
|
`ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha
|
|
declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover
|
|
window (`ENGAGEMENT.md` Phase 13).
|
|
|
|
**As in 1.6.0, the number states the whole surface and the members arrive by phase.**
|
|
`ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7);
|
|
everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled
|
|
on: a member of a declared version that were simply absent would make the version a lie, and one that
|
|
silently accepted data into a table that does not exist would be worse than either.
|
|
|
|
**One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification
|
|
stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot
|
|
attach a payload contract to another module's stream and cannot claim a stream id another module has
|
|
declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the
|
|
same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable
|
|
after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment
|
|
(`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See
|
|
`registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision.
|
|
|
|
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
|
|
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
|
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
|
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
|
|
`registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it.
|
|
|
|
> **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an
|
|
> optional `{ core }` naming which of core's contributions belongs in the declared place, and core
|
|
> offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by
|
|
> the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction
|
|
> and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the
|
|
> inverted direction worked for exactly one module and silently did nothing for any other, which the
|
|
> integration kit found while trying to teach it to an audience outside this org.
|
|
>
|
|
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
|
|
>
|
|
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
|
|
> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and
|
|
> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not
|
|
> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where
|
|
> a Team page actually is, so a notification email could name a Team and not link to it. See
|
|
> `registerTeamProvider` below.
|
|
>
|
|
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
|
|
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
|
|
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
|
|
>
|
|
> **The client slots `team.overview` and `team.member.row` are replaced by the INVERTED direction.**
|
|
> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a
|
|
> surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own
|
|
> the word for one, so the module that owns the vocabulary owns the page. In their place,
|
|
> `registry.declareModuleSlot(id, name, { core })` lets a MODULE declare a place on its own page for
|
|
> CORE to fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
|
|
|
|
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
|
|
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
|
|
module may call any member of this version and get the behaviour documented below.
|
|
|
|
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
|
|
existing one is either the module claiming a mount or core notifying it; the closest precedent is
|
|
`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes
|
|
the envelope, the 10-second budget and the refusal semantics contract rather than implementation —
|
|
they are how a module says "I cannot answer" without core hearing "there is nothing".
|
|
|
|
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
|
|
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
|
|
written by hand (§3.4). Found by the acceptance run in
|
|
[`../modules/kit-acceptance.md`](../modules/kit-acceptance.md): a module built by following the kit
|
|
alone rendered *outside* the site's page column, because the wrapper's two class names belong to
|
|
`theme.css` and appear in no contract. Omitting `shell` is 1.4.0's behaviour exactly, so core's own
|
|
pages are untouched.
|
|
|
|
**Minor, and the table below is why that needs saying.** "A member's signature changes" is a major
|
|
bump, and a prop is a signature — but the rule is about a call that *already exists* changing
|
|
meaning, and an optional prop changes none. Read the table as being about what breaks, not about what
|
|
is typed: adding an optional argument is an addition, and `module-uo`'s `coreApi: "^1.3.0"` still
|
|
resolves.
|
|
|
|
**1.4.0 — Phase 5, the sidecar rule.** §2.7 gained one prohibition: a module does not open a
|
|
connection to a game server from the website process. It talks to a **sidecar**, which owns the
|
|
durable copy of the game's state. No member was added, removed or changed — the surface is identical
|
|
to 1.3.0 — and the bump exists because a module written against 1.3.0 could conform to every member
|
|
and still be built the wrong way round. It is **minor rather than major** deliberately: nothing that
|
|
satisfied 1.3.0's *surface* stops working, `module-uo`'s `coreApi: "^1.3.0"` still resolves, and
|
|
module-uo already complies because the sidecar is where it came from. The reasoning a module author
|
|
needs is [`../../Integration-kit`](https://gitea.whitlocktech.com/RunicGateway/Integration-kit)
|
|
chapter 3; the rule itself is below, because the kit teaches and never re-specifies.
|
|
|
|
**1.3.0 — Phase 3 slice 3, the client half's move.** Three client additions, each because the
|
|
extraction needed it: a nav item may carry an **`icon`** component (§3.3), core declares a third slot
|
|
**`player.invite.accepted`** (§3.7), and `window.__rg.api` gained **`BASE`** — which §3.5 specified
|
|
from the first draft and `shared.js` had never actually published, because nothing needed it until a
|
|
module had to build an EventSource URL. Additions only; the server half is untouched and bumps
|
|
because the two halves state ONE version.
|
|
|
|
**1.2.0 — Phase 3, the client half.** `registry` gained `registerExtension` and core gained client
|
|
extension slots (§3.7). An addition only, and the first change to `window.__rg` since 1.0.0 — the
|
|
server half is untouched, and both files bump because the two halves state ONE version.
|
|
|
|
**1.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and
|
|
`middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`.
|
|
Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an
|
|
admin action a module performs belongs in core's one audit log, the extension slot needs the user its
|
|
prefix names, §2.7 forbids a module reading core's `APP_BASE_URL`, a second rate-limit store is a
|
|
limit enforced by two counters, and core's CMS was calling a UO file directly.
|
|
|
|
Every `module.json` declares a `coreApi` semver **range**. The loader checks it at boot, before it
|
|
requires a line of module code, and a mismatch fails that module loudly into `startup_failed`
|
|
(§4.4) with the two versions in the reason. It never silently proceeds.
|
|
|
|
| Change | Bump |
|
|
| --- | --- |
|
|
| A member is added to `ctx`, or a new `register*` call appears | minor |
|
|
| An **optional** prop or argument is added to an existing member | minor |
|
|
| A member is removed or its signature changes | major |
|
|
| Behaviour of an existing member changes without a signature change | major |
|
|
| A core-internal refactor behind an unchanged member | none |
|
|
|
|
This is a **separate number from `PROTOCOL_VERSION`**, which versions the shard wire and has nothing
|
|
to say about a website module. It is also separate from the module's own version.
|
|
|
|
### 1.2 What is *not* contract
|
|
|
|
Core's internal file layout, table names, middleware ordering, the `api` client object's shape, and
|
|
every component under `client/src/components/` except the ones named in §3.4. A module that reaches
|
|
any of these is out of contract even if it happens to work.
|
|
|
|
---
|
|
|
|
## Part 2 — The server contract
|
|
|
|
### 2.1 `module.json`
|
|
|
|
Read synchronously by the loader from `modules/<id>/module.json`. Unknown top-level keys are
|
|
rejected rather than ignored, so a typo is a loud failure and not a silently-inert setting.
|
|
|
|
```json
|
|
{
|
|
"id": "uo",
|
|
"name": "Ultima Online",
|
|
"version": "1.0.0",
|
|
"coreApi": "^1.0.0",
|
|
"server": "server/index.js",
|
|
"client": { "entry": "client/dist/entry.js" },
|
|
"schema": "server/db/schema.sql",
|
|
"purge": "server/db/purge.sql",
|
|
"mounts": {
|
|
"public": ["/shard", "/atlas"],
|
|
"admin": ["/shard", "/uo-link"],
|
|
"player": ["/shard"]
|
|
},
|
|
"extensions": ["admin.users.detail"],
|
|
"capabilities": ["shard", "atlas", "market"]
|
|
}
|
|
```
|
|
|
|
| Key | Required | Meaning |
|
|
| --- | --- | --- |
|
|
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, the `window.__rg` registry key. Must equal the directory it was read from. |
|
|
| `name` | yes | Human label for the admin Modules screen. |
|
|
| `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. |
|
|
| `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). |
|
|
| `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. |
|
|
| `client.entry` | no | Prebuilt ESM chunk, relative to the module root, and **in a subdirectory** — the directory it sits in is what gets served (§3.1). Absent ⇒ server-only module; present-but-empty is rejected, since it claims a client half and delivers none. |
|
|
| `schema` | no | Idempotent SQL fragment (§2.6). |
|
|
| `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. |
|
|
| `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. |
|
|
| `extensions` | no | Core extension slots this module mounts into (§2.4). |
|
|
| `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules` (§2.9), for clients (the SPA, the Android app) to feature-detect against. Published only while the module is `started`. |
|
|
|
|
### 2.2 The entry point
|
|
|
|
`server/index.js` exports a single function. It is called once, synchronously, during `app.js`
|
|
require — **not** after the database is up.
|
|
|
|
```js
|
|
module.exports = function register(ctx, api) { /* … */ }
|
|
```
|
|
|
|
It must not `await`, must not touch the database, and must not throw for a reason that a retry would
|
|
fix. Everything that needs a live database belongs in `onBoot` (§2.5). This constraint is not
|
|
stylistic: `scripts/routeManifest.js` and `swagger/swagger.js` both require `app.js` with the pool
|
|
pointed at a dead port, and a module that queried at registration time would hang both.
|
|
|
|
### 2.3 `ctx` — what core hands the module
|
|
|
|
Every member below exists because a UO file uses it today. Nothing is speculative, and nothing that
|
|
module-uo does not need is on the list.
|
|
|
|
| Member | Signature | Backed by | First real caller |
|
|
| --- | --- | --- | --- |
|
|
| `ctx.express` | the `express` namespace | core's `node_modules` | every module router (§7.2) |
|
|
| `ctx.validator` | the `express-validator` namespace | core's `node_modules` | `atlas.router.js` |
|
|
| `ctx.db.query` | `(sql, params?) => Promise<rows>` | `utils/db` | every `*.db.js` |
|
|
| `ctx.db.pool` | mariadb pool | `utils/db` | `shardAtlas.db.js` (streamed import) |
|
|
| `ctx.log` | `(namespace) => { error, warn, info, debug }`, each `(msg, meta?)` | `utils/logger` | all nine UO utils |
|
|
| `ctx.settings.get` | `(key) => Promise<string\|null>` | `model/settings` | `shardAtlas.model` |
|
|
| `ctx.settings.set` | `(key, value, updatedBy?) => Promise<void>` | `model/settings` | `shardAtlas.model:60` |
|
|
| `ctx.settings.getInstanceName` | `() => Promise<string>` | `model/settings` | `shardIngest.js:84` |
|
|
| `ctx.auth.getUserFromRequest` | `(req) => { id, username, role } \| null` | `utils/auth` | `shardVisibility.js` |
|
|
| `ctx.push.publish` | `(streamId, { ref?, ownerUserId? }) => Promise<void>` | `utils/pushDispatch:92` | `shardIngest.js:22` |
|
|
| `ctx.secretBox` | `{ encrypt(s), decrypt(s) }` | `utils/secretBox` | `uoLinkConfig.model` |
|
|
| `ctx.middleware` | `{ requireAuth, requireRole, siteMode, validate, noindex }` | `auth/session.middleware`, `middleware/*` | every UO router |
|
|
| `ctx.uploads` | `{ upload, UPLOAD_DIR, MIME_EXT }` | `admin/imageUpload.js` | atlas art import |
|
|
| `ctx.posts` | `{ listAll, getById, linkAnnounceJob, markAnnounced }` | `model/posts` | `newsGump.js:108`, `announceWorker.js:58` |
|
|
| `ctx.paths.moduleRoot` | absolute path to `modules/<id>/` | loader | atlas art, cliloc files |
|
|
| `ctx.activity.log` | `({ req, action, detail }) => Promise<void>` | `model/activity` | every admin UO controller (1.1.0) |
|
|
| `ctx.users.getById` | `(id) => Promise<user\|null>` | `model/users` | `usersShard.controller` (1.1.0) |
|
|
| `ctx.site.baseUrl` | getter, string with no trailing slash | `APP_BASE_URL` | `shardAnnounce` (1.1.0) |
|
|
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
|
|
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
|
|
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
|
|
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
|
|
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
|
|
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
|
|
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) |
|
|
| `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) |
|
|
|
|
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with
|
|
`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
|
|
(`website/ENGAGEMENT.md` §5.2).
|
|
|
|
```js
|
|
ctx.events.emit('uo.house.idoc_warning', {
|
|
subject: '0x40001234', // optional — else read from the declared subjectKey
|
|
data: { house: 'The Silver Anvil', decayStatus: 'Greatly' },
|
|
ownerUserId: 812, // optional — the module resolves it; core never sees a game account
|
|
dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters
|
|
occurredAt: new Date(), // optional, defaults to now
|
|
})
|
|
```
|
|
|
|
Six things about it are contract rather than implementation:
|
|
|
|
- **A module emits its own triggers and nothing else.** The owner is bound by core from the calling
|
|
module's id and is never read from the arguments. Without that, `emit` would be a way to fire
|
|
another module's event with a payload of your choosing, and every rule an operator wrote against
|
|
that trigger would fire on it.
|
|
- **The payload is validated against the declaration at EMIT, not at render.** A missing `required`
|
|
variable or a wrong type is **thrown in development and dropped-and-logged in production** — the
|
|
posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a
|
|
game-event handler, and a contract problem of core's must not become the module's control flow.
|
|
Undeclared keys are dropped rather than rejected; they could never be interpolated anyway.
|
|
- **It returns `undefined` and never throws in production.** There is nothing a module could
|
|
correctly do with a delivery failure from inside an event handler, so there is nothing to await.
|
|
- **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game
|
|
account is and must not learn; the module maps its own account to a user and passes the result.
|
|
- **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls
|
|
back to the variable the declaration's `subjectKey` names.
|
|
- **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused.
|
|
|
|
**`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's
|
|
inbox directly without going through a rule. It is **present and throws** until the in-app channel
|
|
lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent.
|
|
|
|
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
|
|
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
|
|
`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the
|
|
module already holds, in its own store.
|
|
|
|
**`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is:
|
|
|
|
```js
|
|
{ externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? }
|
|
```
|
|
|
|
Four things about it are contract rather than implementation:
|
|
|
|
- **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000
|
|
gold" for a game whose vocabulary it does not know, and a core that templated it would have
|
|
re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are
|
|
likewise opaque — core filters on them and only the module's `team.overview` slot renders anything
|
|
richer than the text.
|
|
- **A Team is named by the module's own `externalId`**, which core maps, and only that module's
|
|
ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an
|
|
archived Team is not writable — its feed is a closed record of what happened before the rename.
|
|
- **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core
|
|
enforces it on the read path.
|
|
- **It never throws at the call site and never rejects.** This is called from inside a game-event
|
|
handler, and a storage problem of core's must not become the module's control flow. A malformed
|
|
item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a
|
|
sidecar reconnect backfill safe to replay.
|
|
|
|
Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership
|
|
change visible at once; `reconcile` is a debounced *request* that returns immediately and never
|
|
rejects. Correctness comes from reconciliation either way, so neither can make a module's own call
|
|
site slow or turn a background failure into the module's error.
|
|
|
|
The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`,
|
|
`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because
|
|
leadership is its own authority path: a leadership change has to be expressible without pretending
|
|
someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry
|
|
`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not
|
|
invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one
|
|
from a delta either, because an archive driven by a message that may simply have been repeated is
|
|
destruction on no evidence.
|
|
|
|
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
|
|
|
|
- **`ctx.auth` is one function, not `utils/auth`.** The facade also re-exports `signToken`,
|
|
`setAuthCookie` and the TOTP challenge primitives. Minting sessions is core's job; a module that
|
|
needs an identity needs to *read* one.
|
|
- **`ctx.settings` is three functions, not the model.** The model exports 24 names, most of them
|
|
registration and app-links policy that is core's business. It said *game-signup* policy too until
|
|
Phase 3 slice 3, which is when that turned out to be wrong in both directions: the setting's own
|
|
help text names Bridge.cfg, so it was never core's — and slice 1 had shipped a ported controller
|
|
calling `settings.isGameAccountSignupEnabled()`, which this narrowing does not expose, so
|
|
`POST /player/shard/account` answered 500 for every caller until slice 3 found it. A narrowing is
|
|
only as safe as the tests that cross it.
|
|
- **`ctx.posts` is four functions.** `create`/`update`/`remove` are the CMS, not a module's.
|
|
|
|
And one addition the spike forced: **`ctx.express` and `ctx.validator`**. A module lives at
|
|
`<repo>/modules/<id>/`, outside `server/`, so Node's resolver never reaches `server/node_modules` and
|
|
`require('express')` from a module simply fails — which is how this was found. Even where it
|
|
resolved, a second express in the process is a second `Router` prototype. Core owns one express, as
|
|
it owns one React (§7.2).
|
|
|
|
`ctx` is frozen (`Object.freeze`, one level deep) before it is handed over. That is a guard against
|
|
accident, not against a hostile module — per `MODULE_SYSTEM.md` §2.2 the boundary is organisational,
|
|
not a security boundary.
|
|
|
|
### 2.4 `api` — what the module registers
|
|
|
|
The second argument. Every call is synchronous, idempotent-free (calling twice is an error), and
|
|
validated at once rather than at first use.
|
|
|
|
```js
|
|
api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
|
|
api.registerExtension(slot, router)
|
|
api.registerNotificationStreams(streams)
|
|
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
|
api.registerPostHook({ onSaved, onDeleted })
|
|
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
|
|
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
|
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
|
|
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
|
|
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
|
|
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
|
|
api.registerEventBudgets([{ id, label, unit }]) // 1.10.0
|
|
api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0
|
|
api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0
|
|
api.onBoot(async (ctx) => {})
|
|
api.onShutdown(async () => {})
|
|
```
|
|
|
|
**Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's
|
|
shape is checked at the call, so a malformed one throws with the registrant's own stack; whether the
|
|
name is *taken* can only be answered once the batch is complete, and is checked when the loader
|
|
commits it in its second pass. The consequence is the one that matters: a module that registers two
|
|
streams and then throws — or fails `checkDeclared` after `register()` returns — has left nothing
|
|
behind. A half-registered catalog would be worse than a missing one, because it is a subscribable
|
|
stream nothing will ever publish to. This is the registry-side twin of §4.3's second-pass mount rule,
|
|
and both exist for the same reason.
|
|
|
|
**`registerRoutes(mounts)`** — one `express.Router()` per prefix per tier:
|
|
|
|
```js
|
|
api.registerRoutes({
|
|
public: { '/shard': shardRouter, '/atlas': atlasRouter },
|
|
admin: { '/shard': adminShardRouter, '/uo-link': uoLinkRouter },
|
|
player: { '/shard': playerShardRouter },
|
|
})
|
|
```
|
|
|
|
The keys must match `module.json`'s `mounts` exactly. Prefixes are validated `^/[a-z0-9][a-z0-9-]*$`
|
|
— one segment, no nesting, no parameters — and rejected on collision with core's own mount table or
|
|
with another module's, at registration time. The router is mounted *inside* the tier, so it
|
|
structurally cannot reach above its prefix.
|
|
|
|
**The tier gate is already applied.** A router registered under `admin` sits behind
|
|
`noindex, isLoggedIn, requireRole('admin','editor','moderator')` from `router/v1/admin/index.js`;
|
|
under `player`, behind `noindex, requireAuth`; under `public`, behind nothing, by design. A module
|
|
adds per-route gates on top of that and never re-implements the tier gate.
|
|
|
|
**`registerExtension(slot, router)`** — the §1.9 case: module routes hanging off a *core* resource.
|
|
Only core may declare a slot; a module may only fill one. Exactly one slot exists in v1:
|
|
|
|
| Slot | Mounted at | Declared by |
|
|
| --- | --- | --- |
|
|
| `admin.users.detail` | `/api/v1/admin/users/:id` | `router/v1/admin/users.router.js` |
|
|
|
|
The router receives `req.params.id` from the parent (`mergeParams: true`). Two modules filling the
|
|
same slot is a collision and is rejected; core's own routes on the resource always win a path
|
|
conflict.
|
|
|
|
**`registerNotificationStreams(streams)`** — §1.8's push catalog.
|
|
An array of `{ id, label, description, personal, requiresLinkedAccount }` appended to core's catalog.
|
|
Ids are namespaced `<moduleId>.<name>` and rejected otherwise, save for the seven grandfathered ones
|
|
in §6.4.
|
|
|
|
Two amendments this signature carries, both settled 2026-08-10 with PR 4:
|
|
|
|
- **`mapEvent` is gone.** The earlier signature took `{ streams, mapEvent }`, with core's dispatcher
|
|
calling `mapEvent(event) => streamId`. That was a leftover from before §1.8's push inversion was
|
|
settled: the module owns `fromShardEvent` outright and calls `ctx.push.publish(streamId, …)` with
|
|
an id it has already resolved, so core never needs a second way to get there. What core wants from
|
|
a module here is the catalog — for the subscribe endpoint, for validating a subscription write, and
|
|
for the personal/linked-account gate. It follows that the public-safety filter (a sensitive event
|
|
kind can never produce a *public* push) is module-internal; that is the right home, because the
|
|
kinds, the streams and the filter are then one file that moves together, rather than a rule in core
|
|
about data only the module defines.
|
|
- **Two booleans, not one `scope`.** The entry shape above is the response body of
|
|
`GET /auth/me/notifications/streams`, which a shipped Android client already reads
|
|
(`NotificationsDto.kt`). `scope` was never the wire shape.
|
|
|
|
**`registerAnnounceLeg({ leg, label, dispatch, classify })`** — §1.8's news dispatcher.
|
|
`leg` is a namespaced id, `label` is what the admin panel shows, `dispatch(post) => Promise<result>`
|
|
delivers, and `classify(result) => { outcome, error }` maps the client's result to
|
|
`done` / `retry` / `terminal`. A leg that throws is caught, classified as a retry, and never blocks
|
|
another leg.
|
|
|
|
`label` is an addition: the panel used to hold a client-side `{ towncrier, discord }` label table,
|
|
which would have left a module's leg rendering as a bare id. It comes from the registration so a
|
|
module needs no client change.
|
|
|
|
**Legs are rows, not columns.** `announce_jobs` carried a `towncrier_*` and a `discord_*` column
|
|
group until PR 4; a module cannot `ALTER` a core table, so a registered leg had nowhere to live. The
|
|
per-leg state moved to `announce_job_legs (job_id, leg, status, attempts, last_error,
|
|
next_attempt_at)` and `leg` is a stored value. The parent `status` rollup is over *all* the job's
|
|
legs — done when every leg delivered, failed when every leg gave up, partial in between; and `done`
|
|
when a job has no legs at all, since nothing is left to deliver.
|
|
|
|
**`registerPostHook({ onSaved, onDeleted })`** — added in API 1.1.0. Core's CMS is the only writer of
|
|
posts, and a module may need to mirror one somewhere core knows nothing about. `onSaved` receives
|
|
`{ post, transition }` — the same transition `registerAnnounceLeg` fires on — and `onDeleted`
|
|
receives `{ post, id }`. Both are optional; a registration with neither is refused, since it is a
|
|
subscription that can never fire. One hook per registrant.
|
|
|
|
Every hook is awaited and none may throw past core: a subscriber's failure is logged and costs
|
|
neither another subscriber nor the save itself. A sidecar hiccup breaking a post edit would be a
|
|
worse bug than a stale mirror.
|
|
|
|
**It is deliberately not part of `registerAnnounceLeg`**, which fires on the same transition. A leg
|
|
is a one-shot *delivery* with retry and classification; a post hook maintains idempotent *state*, has
|
|
to run on delete as well as save, and refreshes silently on an edit. Overloading the leg would have
|
|
meant a `dispatch` that must not be retried and a `classify` that means nothing.
|
|
|
|
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
|
|
naming a UO file, and the last thing binding core to the module.
|
|
|
|
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
|
|
module becomes the authoritative source of Teams for this deployment. Two further members,
|
|
`projectRoster` and `pageUrlTemplate`, are optional and documented below.
|
|
|
|
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
|
|
one authoritative source by construction, and two modules answering "what Teams exist" would produce
|
|
two disjoint sets under one table with no rule for merging them. A second registration is a
|
|
collision, reported against the module that holds it. Three methods are required — a provider
|
|
that could list Teams but not their members would leave core holding Teams it can never populate,
|
|
which is not the same as a call that fails. The fourth is optional; see below.
|
|
|
|
```js
|
|
getTeams() // () => Promise<{ ok, complete?, teams }>
|
|
getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
|
|
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
|
|
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
|
|
// => Promise<{ ok, members }> // members = [memberKey]
|
|
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
|
|
// e.g. '/uo/guilds/{externalId}'
|
|
|
|
// authoritative
|
|
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
|
|
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
|
|
{ ok: false, reason: 'sidecar unreachable' }
|
|
```
|
|
|
|
A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is
|
|
resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be
|
|
core reading a module's table by name.
|
|
|
|
**Every method returns an envelope, never a bare array, and this is the load-bearing part of the
|
|
contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a
|
|
non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate
|
|
`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is
|
|
the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is
|
|
the one a module returns while its sidecar is still connecting.
|
|
|
|
A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and
|
|
surfaces it. It never empties a roster on an answer it does not trust.
|
|
|
|
**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It
|
|
answers *who is allowed to look at a roster*, on the request path, because the audience model and its
|
|
configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question,
|
|
"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to
|
|
whoever asked. So this one call **fails closed**.
|
|
|
|
Core distinguishes two refusals, and a module does not have to do anything to get the right one:
|
|
|
|
- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and
|
|
nothing is being withheld, so core serves the roster whole at its own public shape. This is what
|
|
makes the member genuinely optional: bare core, and a module with no rungs of its own, both render
|
|
the page core writes.
|
|
- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core
|
|
serves an empty roster and says so in the response (`projected: false`,
|
|
`projectionUnavailable: true`).
|
|
|
|
**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core
|
|
cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md
|
|
Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary
|
|
owns the page. That is settled and right, and it leaves core unable to write the link a notification
|
|
email needs — an email about a forum reply that cannot take you to the thread is most of the way to
|
|
useless. So the module that owns the page says where it is.
|
|
|
|
```js
|
|
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders,
|
|
pageUrlTemplate: '/uo/guilds/{externalId}' })
|
|
```
|
|
|
|
Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only** —
|
|
a template naming its own host is refused at registration, since there is no reason for a module to
|
|
redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the
|
|
member costs the deployment clickable links in Team notification email and nothing else.
|
|
|
|
**Data rather than a callback, deliberately.** A function here would put a module hook on the mail
|
|
path — one more thing that can hang or throw between a forum reply and the mail about it — to produce
|
|
a string that never varies.
|
|
|
|
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
|
|
an anonymous caller — and never the `users` row, which would make every column of that table part of
|
|
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
|
|
published row looks like and re-normalises whatever comes back through its own public shape, so a
|
|
module can narrow which rows appear and cannot widen which fields do: the member key and the site
|
|
account id are withheld from every public roster whatever a module returns.
|
|
|
|
`complete: false` means "valid but partial": core applies additions and updates and performs **no**
|
|
removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony.
|
|
|
|
**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly
|
|
omitted from a roster is indistinguishable, downstream, from that member having left — core would
|
|
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
|
|
|
|
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
|
|
belong to the module, live since phase 7 (TEAMS.md §7.1).
|
|
|
|
```js
|
|
api.registerSlashCommands([{
|
|
name: 'guild', // lowercase, 1-32, no dots
|
|
description: 'Show a guild on this shard', // 1-100 characters
|
|
options: [ // the restricted schema, below
|
|
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
|
|
],
|
|
access: 'everyone', // 'everyone' | 'linked' | 'staff'
|
|
async handler({ command, options, actor }) {
|
|
return { title, text, fields, url, ephemeral, notice } // every field optional
|
|
},
|
|
}])
|
|
```
|
|
|
|
**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules`
|
|
volume and cannot load a line of module code, so it pulls the definitions over an internal API and
|
|
owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality,
|
|
follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding
|
|
a Discord handle, and this split is the reason a second platform could implement the same contract.
|
|
|
|
**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler
|
|
learns about the caller:
|
|
|
|
| field | |
|
|
| --- | --- |
|
|
| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees |
|
|
| `platformUserId` | the caller's id on that platform |
|
|
| `guildId` | the platform community the command was run in, or `null` |
|
|
| `userId` | the site account, or `null` when the platform identity is not linked |
|
|
| `role` | that account's role — a module with audience rungs needs more than a boolean |
|
|
| `isLinked` | whether `userId` resolved |
|
|
| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means |
|
|
|
|
A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban
|
|
does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an
|
|
operator-chosen slug.
|
|
|
|
**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side
|
|
permission default from it where the platform can express one; core re-checks it in the dispatcher on
|
|
every call. `'linked'` has no Discord equivalent at all — there is no "has a website account"
|
|
predicate — so it is simply not advertised, which is exactly why the client half cannot be the
|
|
boundary.
|
|
|
|
**The option schema is deliberately small: `string | integer | boolean | user`,** each with
|
|
`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete,
|
|
attachments, modals or component interactions — those are the features whose semantics do not survive
|
|
a second platform. A command needing them is a bot-side command, written in the bot.
|
|
|
|
**A definition the platform would reject fails at `register()`**, not at the next connection: the bot
|
|
registers the whole set in one call, so one bad option type would cost every command, the bot's own
|
|
included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option
|
|
types, and the ordering rule that a required option may not follow an optional one.
|
|
|
|
**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's
|
|
name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that
|
|
collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core
|
|
cannot see.
|
|
|
|
**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes
|
|
a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything
|
|
but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it.
|
|
|
|
**A disabled module's commands stop answering immediately.** Registration has no removal path — a
|
|
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
|
|
switches a module off does not leave a live handler behind it.
|
|
|
|
**`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload
|
|
contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves,
|
|
what a rule is written against, and what a template may interpolate
|
|
(`website/ENGAGEMENT.md` §4.3).
|
|
|
|
```js
|
|
api.registerEventTriggers([{
|
|
id: 'uo.house.idoc_warning', // <owner>.-prefixed, one namespace with stream ids
|
|
label: 'House approaching collapse',
|
|
description: 'A player house dropped into a late decay stage.',
|
|
kind: 'event', // 'event' | 'scheduled'; default 'event'
|
|
subjectKey: 'house', // which variable identifies the cooldown subject
|
|
audience: 'owner', // the DEFAULT a rule is created with
|
|
ceiling: 'owner', // the widest a rule may EVER be given
|
|
version: 1, // bumped on a rename or a type change
|
|
variables: [
|
|
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
|
|
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
|
|
],
|
|
}])
|
|
```
|
|
|
|
Six things about it are contract rather than implementation:
|
|
|
|
- **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across
|
|
both facets. A module may declare both for the same id — that is one event with a subscription
|
|
toggle *and* a payload contract, and it is what core does with its own five — but it may not attach
|
|
a contract to another owner's stream, and the refusal names the holder and the facet. The seven
|
|
grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams,
|
|
because under one namespace they are the same ids.
|
|
- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a),
|
|
and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated`
|
|
would silently widen a staff-only event. The seven values are `everyone`, `authenticated`,
|
|
`subscribers`, `members`, `staff`, **`admin`** (1.8.0, a child of `staff`) and `owner`, ordered by
|
|
**containment and not by size** — a `staff` ceiling does NOT permit `owner`, because fewer people is
|
|
not less exposure. `staff` → `admin` is the single true refinement in the tree and the only pair
|
|
below `authenticated` that `permits` accepts. A default `audience` wider than, or incomparable with,
|
|
the ceiling is refused at registration.
|
|
- **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and
|
|
test-sending a template possible without a live game event, which is the reason template systems go
|
|
untested. A variable without one is refused.
|
|
- **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and
|
|
no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is
|
|
validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href.
|
|
- **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`,
|
|
which looks like the feature working right up until two subjects share it.
|
|
- **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type
|
|
change; a template records what it was authored against and renders with a warning rather than
|
|
interpolating `undefined`.
|
|
|
|
A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a
|
|
prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by
|
|
`npm run engagement:manifest` and gated in CI with `--check`.
|
|
|
|
**`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its
|
|
own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the
|
|
governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it.
|
|
|
|
```js
|
|
api.registerAudiences([{
|
|
id: 'uo.team.members',
|
|
label: 'Members of a team',
|
|
params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only
|
|
ceiling: 'members',
|
|
resolve: async (params) => [/* user ids */],
|
|
}])
|
|
```
|
|
|
|
Four things about it are contract rather than implementation:
|
|
|
|
- **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an
|
|
address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not
|
|
become the back door that lets it — core maps ids to addresses on its own side, after preferences,
|
|
suppression and the verification gate.
|
|
- **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label
|
|
and a `resolve` it may call. The same boundary `registerNotificationStreams` holds.
|
|
- **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty
|
|
set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a
|
|
*different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A
|
|
resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it
|
|
does return are filtered to positive integers before core uses them.
|
|
- **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still
|
|
checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the
|
|
intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all
|
|
and the save is refused rather than guessed. Composition UI is Phase 4's.
|
|
|
|
**Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE
|
|
and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing
|
|
predates them.
|
|
|
|
**`registerEngagementSeeds({ templates, ruleGroups })`** (1.9.0) ships the module's own message
|
|
BODIES and its seeded rules (`ENGAGEMENT.md` Phase 11b, decision 7). It is the content behind the two
|
|
declarations above: they say what an event IS and who it is about, and this says what the mail reads
|
|
like. Callable once per module. The full shape, the three prohibitions and the boot-path placement
|
|
are in §1.1 under **1.9.0**; four things are contract rather than implementation and belong here:
|
|
|
|
- **Templates re-ensure; rule groups are one-shot.** A body is offered again on every boot under
|
|
`seed_key` / `seed_version` / `customized`, so improving a default reaches deployments that did not
|
|
edit it. A rule is offered ONCE per named group, because re-offering would resurrect a rule an
|
|
operator deleted and reset one they enabled. **A rule appended to an existing group reaches fresh
|
|
installs only** — that is not a limitation to work around, it is the guarantee; a rule that must
|
|
reach existing deployments takes a new group key.
|
|
- **A seeded rule is always disabled.** `enabled` is not a parameter. An operator turns a module's
|
|
mail on; installing a module never does.
|
|
- **Core's generic bodies are a first-class answer.** A rule may point `template_keys` at
|
|
`notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a
|
|
bespoke body when the message has something to say that the structural projection cannot; a trigger
|
|
whose message is "this happened, here is the link" should not have one.
|
|
- **`digest` is a slot, not a channel.** It names the body the digest worker renders for a rule whose
|
|
email channel an individual set to digest mode, so it is legal in `template_keys` and never appears
|
|
in `channels`. Every other key must be one of the rule's channels.
|
|
- **It is not a send path.** Every value on the object is data. Core still decides who is told.
|
|
|
|
**`registerEventActions([...])` / `registerEventBudgets([...])` / `registerEventLeases([...])` /
|
|
`registerEventOptionSources([...])`** (1.10.0) are the event contract (`EVENTS.md` §F). The full
|
|
shapes and the ten rules that come with them are in §1.1 under **1.10.0**; six things are contract
|
|
rather than implementation and belong here:
|
|
|
|
- **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s
|
|
dispatch, and unlike everything above them — but from further away than either, because the thing
|
|
on the other end may be a shard. That is why `budgetMs` is declared per action and enforced by the
|
|
dispatcher: without it a `perform()` awaiting a socket that never answers holds a step's claim
|
|
until its lease expires, and the reclaim then re-dispatches it, which is how one wedged sidecar
|
|
becomes an infinite loop rather than a failed step.
|
|
- **`budgetMs` must EXCEED the timeout of whatever the action talks to** (Events Phase 9). The
|
|
dispatcher classifies a budget timeout as `retry` unconditionally and does not ask the action —
|
|
it cannot, the action is still awaiting a socket. So an action whose own client gives up *after*
|
|
core's deadline never gets to classify its own failure, and `retry: false` in its envelope is
|
|
unreachable code. The default `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, which
|
|
is the wrong way round: every slow shard produced a retry the module had explicitly refused. The
|
|
rule generalises past that one pairing — an action is the near end of a call with a far end, and
|
|
the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it
|
|
is attempted once, declares 15000.
|
|
- **A module reports who took part on the envelope, and there is no other door.** `participants`
|
|
rides back from `perform()` exactly as `resources` does, on both success shapes — including
|
|
`await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is
|
|
therefore the only moment its participants can be recorded. There is deliberately no
|
|
`ctx.events.participants` and no route: a second write path into a run core is mid-tick on would be
|
|
a second thing that can race the step claim. One step may report at most 5000, the same bound the
|
|
engagement engine puts on a list of users a caller may assert, and a member reported twice in one
|
|
step is recorded once with the duplicate named.
|
|
- **A declaration's ceiling bounds the kind of event; a firing's `ceiling` bounds the occasion.** An
|
|
emitter that already knows this particular firing must not reach as far as the declaration allows
|
|
passes one, and the gate takes the meet. It only narrows — passing a wider value changes nothing —
|
|
and passing something incomparable with the declaration refuses every rule rather than resolving
|
|
to either. See §1.1 under 1.10.0.
|
|
- **`once`, on all four.** A batch is a module's complete statement about what it declares; a second
|
|
call is a module changing its mind halfway through `register()` rather than adding to it. And they
|
|
STAGE, like every registration above: a module that registers two budgets and then throws has left
|
|
nothing behind.
|
|
- **Everything is optional, and §F says so once because it governs every member.** A module may
|
|
register no actions, no budgets, no leases and no option sources. Each registration *adds* what an
|
|
author can reach for; a module that omits one costs its deployment a capability rather than a boot,
|
|
exactly as a module with no `onBoot` still reaches `started`.
|
|
- **An option source that refuses degrades its field to free text with a warning.** It never blocks
|
|
the authoring form and it never raises. The alternative is a screen a module's outage can take
|
|
away, for a field whose value the operator very often already knows — which is a worse failure than
|
|
the typo the dropdown exists to prevent.
|
|
- **Core records what an action made BEFORE the action is dispatched, not after** (`EVENTS.md` §D
|
|
rule 1). A module's `resources` are the refs core did not know until the answer arrived; what core
|
|
wrote beforehand is a placeholder keyed by the step's idempotency key, so a dispatch whose answer
|
|
never came back is still something cleanup can act on. The consequence for a module author is the
|
|
whole reason `revert` takes `idempotencyKey` as well as `resources`: it will sometimes be called
|
|
with the key and an EMPTY list, meaning *"a command went out under this key and core never learned
|
|
what it did"*. Answering that honestly is what makes an unattended world write recoverable; a
|
|
module that cannot answer it says so, and the row stays visible to an operator.
|
|
- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an
|
|
action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an
|
|
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it
|
|
runs on every terminal path — completion, cancellation and abort alike — so a module's only job is
|
|
to answer `revert` correctly however many times it is asked.
|
|
|
|
**An action whose module is uninstalled goes dormant, never an error.** A step already in a saved
|
|
spec keeps it and a new step may not add one — the shape `engagement_rules` established for a dormant
|
|
trigger — and a dormant step blocks the PUBLISH, because a version is what a run pins and a run
|
|
cannot dispatch a verb nobody registers. A step that reaches dispatch naming one fails `terminal`
|
|
with the module named and the run degrades: never a silent skip.
|
|
|
|
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
|
|
|
### 2.5 Lifecycle
|
|
|
|
```
|
|
(core schema + seed) → MODULES resolved onto the volume ← §2.7.2 decision 4
|
|
↓
|
|
require(module) → register(ctx, api) → [routes mounted, app.js require returns]
|
|
↓ (server.js: schema fragments, then)
|
|
onBoot(ctx) → started
|
|
↓ (SIGINT/SIGTERM)
|
|
onShutdown()
|
|
```
|
|
|
|
`onBoot` is where the eight `server.js` UO call sites go (`MODULE_SYSTEM.md` §1.7): the atlas and
|
|
cliloc refreshes, the market display-name backfill, `uoLinkSocket.start()`, the sidecar probe.
|
|
It runs **after** `ensureSchema()` (so the module's own tables exist) and after `seedDefaults()`,
|
|
and **before** the HTTP listener binds — a module that must not serve traffic before it has warmed
|
|
its cache gets that for free.
|
|
|
|
`onShutdown` runs before anything core owns is closed — the database pool, the push dispatcher and
|
|
the SSE fan-out are all still open, because a module's `onShutdown` is the only chance it gets to
|
|
flush through them. Reverse registration order, with a 5-second budget per module; exceeding it is
|
|
logged and the hook abandoned rather than hanging the process. Abandoned, not cancelled: nothing can
|
|
stop a promise that is still running, but the process is exiting anyway and the alternative is a host
|
|
where `systemctl stop` waits for SIGKILL.
|
|
|
|
**`onBoot` has no budget, deliberately.** Shutdown races the process being killed; boot does not. A
|
|
slow `onBoot` delays the listener binding, which is the guarantee two paragraphs up rather than a
|
|
problem to be timed out, and core's own boot steps are awaited exactly the same way.
|
|
|
|
Both hooks are optional, and both are individually try/caught. An `onBoot` that throws marks that
|
|
module `startup_failed` (§4.4) and the site still comes up — its routes stay mounted but its dispatch
|
|
guard rejects them with 503, because a module that failed to warm up serving half-initialised data is
|
|
worse than a module that says it is down. A module with no `onBoot` at all still reaches `started`:
|
|
having nothing to warm up is not the same as never having started, and the row has to agree with the
|
|
guard about whether the module is serving. A module whose `onBoot` threw gets **no** `onShutdown` — it
|
|
is part-way through a warm-up it never finished, and handing it a half-built world to tear down is
|
|
worse than not closing cleanly.
|
|
|
|
`onBoot` receives the same frozen `ctx` object `register()` was given, not a second one built to look
|
|
like it.
|
|
|
|
**What a boot does to `installed_modules`** (`MODULE_SYSTEM.md` §2.4). The dispatch is the second half
|
|
of a reconcile, and the order of its four steps is the design:
|
|
|
|
1. Clear the last boot's outcomes, so what is on display afterwards is what *this* boot did.
|
|
`disabled` rows are left alone — that is an operator decision, not an outcome.
|
|
2. Write a row for every module found on the volume, with null provenance if it has none. A directory
|
|
placed on the volume by hand is a supported install (§2.5 of the design of record) and without a
|
|
row it could be neither disabled nor reported.
|
|
3. Mark any row whose directory is **not** on the volume `startup_failed` (stage `require`). Step 1
|
|
has just reset it to `enabled`, and a row claiming to be enabled for a module that is not there is
|
|
the one state that is simply untrue. A plain uninstall leaves `disabled`, which step 1 never
|
|
touches, so this catches only a directory deleted by hand.
|
|
4. Write down the outcome each module already carries — disabled by the operator, or failed during
|
|
load or schema replay, both of which happen before the database is reachable — and only then
|
|
dispatch `onBoot`.
|
|
|
|
**The operator's switch wins over everything, including a failure.** A module whose row says
|
|
`disabled` is guarded (§4.5), is not booted, and does **not** have its failure re-recorded:
|
|
overwriting a deliberate `disabled` with an outcome would silently switch it back on at the next
|
|
boot.
|
|
|
|
**A bookkeeping failure is not a boot failure.** Every database write in the reconcile is individually
|
|
caught. A row that will not update is bad — the admin panel shows the wrong thing — but it is
|
|
strictly less bad than a site that will not start, and it must not stop the modules behind it from
|
|
booting.
|
|
|
|
Dispatch and reconcile live in `server/src/modules/lifecycle.js`, not in the loader: `routeManifest.js`
|
|
and `swagger.js` both require `app.js` against a dead pool (§4.1), so the loader may not reach the
|
|
database. The two halves meet at exactly one place — `loader.setState()` — so the in-memory record
|
|
the dispatch guard reads and the row the admin panel reads are moved together and cannot disagree.
|
|
|
|
### 2.6 Schema fragments
|
|
|
|
`schema` is an idempotent `.sql` file replayed immediately after core's own `schema.sql`, statement
|
|
by statement, split the same way. It is subject to the same rules
|
|
core's file already follows: `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`,
|
|
no `--` inside a string literal, no `DROP`.
|
|
|
|
"Split the same way" is shared code, not a shared description: `utils/sqlStatements.js` holds the
|
|
splitter and both callers use it. It is its own file rather than an export of `utils/db.js` because
|
|
the loader validates fragments at require time and must not pull the mariadb pool into `app.js`'s
|
|
require chain to do it.
|
|
|
|
**The rules above are enforced at LOAD time, not at replay time** (PR 3). Everything §2.6 states
|
|
about the SQL is knowable by reading the file, so a fragment that breaks a rule costs the module its
|
|
mount entirely (§4.4's left-hand column) rather than mounting and then 503ing with tables half
|
|
created. What is left for the replay is the class of failure only the database can report — an
|
|
unknown column type, a bad foreign key — and those are post-mount and answer 503.
|
|
|
|
**The check is a leading-verb allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Those are the four
|
|
core's own `schema.sql` uses. It is an allowlist rather than the `DROP` denylist this section words
|
|
it as because a fragment is **replayed on every boot**: `TRUNCATE` and `DELETE` would empty a table
|
|
at every restart, `RENAME` would fail at the second one, and `GRANT`/`SET`/`USE` are core's business.
|
|
A denylist only ever bans what somebody thought of. It is a leading-verb check and claims no more:
|
|
`ALTER TABLE x DROP COLUMN y` passes it, and catching that needs a SQL parser — a large dependency
|
|
for a rule whose job is stopping the obvious foot-gun early. A `CREATE TABLE` missing `IF NOT EXISTS`
|
|
is rejected on the same grounds: it succeeds exactly once and fails every boot after, presenting to
|
|
an operator as a module that broke on restart.
|
|
|
|
**The replay is outside `ensureSchema()`'s retry loop.** Core's schema is retried ten times while the
|
|
database comes up; a fragment that throws is one module's failure, not a signal the database is not
|
|
ready, and retrying core's whole schema over one module's bad SQL would turn a 503'd module into a
|
|
two-minute boot. Partial application is accepted rather than compensated for — MariaDB self-commits
|
|
each DDL statement, so no transaction could roll back the tables created before the failing one, and
|
|
the idempotence rule is what makes re-running a corrected fragment safe.
|
|
|
|
**One caller replays nothing, deliberately.** `db/seed.js` (`npm run seed`) calls `ensureSchema()`
|
|
standalone without requiring `app.js`, so no scan has happened and `fragments()`'s §7.6 throw would
|
|
break seeding outright. The replay asks `isLoaded()` and logs the skip. That is the only sanctioned
|
|
use of that predicate: everywhere else, reading the module list before `load()` still throws, because
|
|
a booting server quietly getting no module tables is precisely what §7.6 exists to prevent.
|
|
|
|
**And the server replays them itself, because it now scans the volume LATER than it ensures the
|
|
schema.** `ensureSchema()` carries the replay for every ordinary caller, but `server.js` passes
|
|
`replayModules: false` and calls `replayFragments()` of its own accord after requiring `app.js`. The
|
|
reason is `MODULES` (`MODULE_SYSTEM.md` §2.7.2 decision 4): resolving a declared module set has to
|
|
happen before the scan, it needs the host-allowlist setting to do it, and that setting does not exist
|
|
until the schema and the seed have run — so core's schema now precedes the scan, and a replay wired
|
|
to core's schema would run when there was nothing yet to replay. Nothing about the contract moves:
|
|
fragments still run after core's tables exist and before any `onBoot`. **This was a live defect for
|
|
the length of one afternoon**, and the shape of it is worth keeping: it announced itself only as the
|
|
skip line above, appearing in a *booting server's* log where it means the opposite of what it means
|
|
in `npm run seed`, and on a database whose tables already existed the module started perfectly.
|
|
|
|
**Table names are namespaced and collision-checked.** New tables must be prefixed `<id>_`. The
|
|
loader extracts every `CREATE TABLE IF NOT EXISTS <name>` from the fragment and rejects the module
|
|
if a name collides with a core table or with another module's — a wrong `DROP`-free fragment can
|
|
still silently adopt someone else's table otherwise.
|
|
|
|
**module-uo is grandfathered.** Its 27 tables are named `shard_*` (26) and `uo_link_config` (1), and
|
|
renaming them is a data migration this workstream explicitly does not do (`MODULE_SYSTEM.md` §1.6
|
|
puts the count at 25; the working tree says 27 — see §6.4). They are registered in the loader as an
|
|
explicit legacy allowlist keyed to `id: "uo"`, so the prefix rule holds for every module written
|
|
after this one.
|
|
|
|
`purge` is the destructive counterpart, run **only** by the explicit admin purge action, never by
|
|
uninstall. Required whenever `schema` is present: a module that can create tables and cannot drop
|
|
them leaves an operator with orphaned data and no supported way to remove it.
|
|
|
|
### 2.7 What a module must not do
|
|
|
|
- `require` anything outside its own directory except node built-ins and its own `dependencies`.
|
|
- Mutate `ctx`, `req.user`, or any object core handed it.
|
|
- Register an Express error handler, or any middleware at the app level.
|
|
- Read `process.env` for core configuration. Its own config is a `settings` key or its own table.
|
|
- Call `process.exit`, install signal handlers, or start a listener.
|
|
- Write outside `ctx.paths.moduleRoot` and the upload directory.
|
|
- **Open a connection to a game server from the website process** — a game socket, an RCON channel,
|
|
a query port, an engine's admin API. A module talks to a **sidecar**, and the sidecar talks to the
|
|
game. Added in 1.4.0.
|
|
|
|
**Why the sidecar is not optional** (added 2026-08-12; the reasoning, and the worked example, are
|
|
Integration Kit chapter 3 — [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.11):
|
|
|
|
- **The website is the internet-facing process and the game is not.** A module dialling the game
|
|
directly makes the public web app the thing the game trusts, and puts the game's address in the
|
|
same process as every request from the internet. With a sidecar, the game dials **out** and opens
|
|
no listening port at all, which is the invariant [`../link/PLAN.md`](../link/PLAN.md) exists to
|
|
hold.
|
|
- **The sidecar owns the durable copy.** It is a non-blocking dumb forwarder that **persists before
|
|
it forwards** — `uo-link` writes every event and every board snapshot to SQLite (`store.rs`) and
|
|
serves its REST reads from there. So a website that is down, restarting or mid-deploy loses
|
|
nothing, and a page renders the last thing the game said instead of going blank. A module holding
|
|
the connection itself has nowhere to put what arrives while the website is not running.
|
|
- **Neither side can stall the other.** The game's plugin enqueues onto a bounded drop-oldest queue
|
|
and a writer thread drains it; the live feed is best-effort and lossy on purpose (a lagging
|
|
consumer drops frames) because durability is the store's job, not the socket's. A module that owns
|
|
the socket inherits both problems inside an Express process, where the failure mode is a wedged
|
|
request handler.
|
|
|
|
This one is **normative prose with no CI behind it** — an outbound socket is not statically
|
|
detectable the way an internal `require` is (§5.1), so it is enforced in review. Stated as a rule
|
|
anyway, because the alternative is that every second module re-decides it, and the first one to
|
|
decide wrong finds out during an outage.
|
|
|
|
### 2.8 The OpenAPI fragment
|
|
|
|
Every module that registers routes ships `swagger-fragment.json` in its bundle root. Core merges the
|
|
fragments of started modules into `/api/docs.json`; the full reasoning and the collision rules are
|
|
§6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered
|
|
route has no path in the fragment, and core wins every key collision.
|
|
|
|
**Built in phase 3 slice 5** (module-uo#6, website#141). Four things settled while building it, all
|
|
of which a second module inherits:
|
|
|
|
- **The filename is fixed here, not declared in `module.json`.** `swagger-fragment.json` in the
|
|
bundle root, like `module.json` itself — so a module cannot point core at some other file, and
|
|
core's loader has one path to check. A module that ships none is simply absent from the merged
|
|
document: whether it registered routes without documenting them is the *module's* CI to answer,
|
|
where the routes are known. Core cannot tell a module with no routes from one that forgot.
|
|
- **Namespace what you DEFINE; reference core's by core's name.** `UoShardStatus` is defined by the
|
|
module; `#/components/schemas/Error` and `ValidationError` are referenced and **not** redefined.
|
|
Both resolve in the merged document, which is the only place both halves exist — and shipping a
|
|
copy of `Error` would be a collision core drops, arriving at the same result the expensive way.
|
|
This is the practical form of "core wins": it makes the two cases feel different in the source,
|
|
which is where the mistake would otherwise be invisible.
|
|
- **Generate the fragment from the module's own registrations.** swagger-autogen needs a *file* and
|
|
cannot follow `api.registerRoutes`, so the module's generator runs its own `register()` against a
|
|
recording `api` and resolves each router back to its source through `require.cache`. A mount prefix
|
|
then exists in exactly one place. The two values it cannot derive — the tier base paths and the
|
|
slot's mount, both §2.4's — are checked against a real core by the §5.3 job rather than trusted.
|
|
- **swagger-autogen reports a broken annotation and then succeeds.** It `console.error`s "Syntax
|
|
error" or "out of structure", drops that annotation, and prints `Success`. Both repos' generators
|
|
now capture those diagnostics and fail on them, which found six annotations documenting less than
|
|
they claimed. Two ways one breaks: an object literal a brace short, and a `"` or backtick inside a
|
|
single-quoted description (the tool re-quotes both to `'` before evaluating, ending the string
|
|
early). A third, which nothing but a rendered page catches: an escaped apostrophe survives
|
|
literally, because the annotation is not evaluated as JavaScript.
|
|
|
|
### 2.9 What core publishes about a module
|
|
|
|
`GET /api/v1/public/modules` — anonymous, database-free, never site-mode gated.
|
|
|
|
```json
|
|
{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "1.0.0",
|
|
"capabilities": ["shard", "atlas", "market"] } ] }
|
|
```
|
|
|
|
Four fields, in the loader's scan order (§4.2). What is *not* there is the design:
|
|
|
|
- **Only `started` modules appear.** The endpoint answers what this backend is serving. A module that
|
|
is `disabled` or `startup_failed` is **absent**, which is the same answer §4.4 already gives for its
|
|
routes and its nav — a client renders a site without that capability rather than one advertising a
|
|
capability that 503s. `installed` and `registered` are likewise absent: neither is serving yet.
|
|
- **No `state`, no `failure_stage`, no `failure_reason`.** Where a module broke and how far it got is
|
|
operator detail for the admin Modules screen. An anonymous visitor is not told that something is
|
|
broken, and the message — which is an exception string from inside core — never leaves the server.
|
|
- **No `client` chunk URL.** `utils/htmlShell.js` injects a `<script type="module">` per started
|
|
module (§3.1.3), so the browser is handed the tag rather than a URL to fetch. This endpoint is for
|
|
**feature detection**, not for loading. `MODULE_SYSTEM.md` §2.6 step 4 predates that resolution and
|
|
is amended to match (§6.7).
|
|
- **An empty `modules` array is a real answer** — a core with nothing installed. The one thing that is
|
|
not an answer is the §7.6 guard: reading the list before `modules.load()` ran is a **500**, never
|
|
`[]`, because a caller cannot tell an empty list from a mis-ordered boot.
|
|
|
|
`capabilities` are opaque to core: it never interprets one, and two modules may declare the same
|
|
string. A client must treat an unknown capability as absent and must not infer a route from one — the
|
|
mount prefixes are `module.json`'s business (§2.3), not the capability list's.
|
|
|
|
The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own
|
|
rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads
|
|
the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route
|
|
declared inside the root-mounted site router would be invisible to it — a real `use('/modules', …)`
|
|
layer is what makes "no module may claim `/modules`" enforced rather than merely intended.
|
|
|
|
---
|
|
|
|
## Part 3 — The client contract
|
|
|
|
### 3.1 How the chunk gets there
|
|
|
|
Exactly as `MODULE_SYSTEM.md` §2.6 resolved, and Phase 1's spike is what proves it:
|
|
|
|
1. Module CI builds `client/dist/entry.js` with Vite in **library mode**, `react`, `react-dom`,
|
|
`react-dom/client` and `react-router-dom` declared **external**.
|
|
2. Core serves the **directory the entry sits in** statically at `/modules/<id>/` — same-origin, so
|
|
`script-src 'self'` (`config/csp.js:49`) admits it with no nonce and no inline.
|
|
3. `utils/htmlShell.js` injects `<script type="module" src="/modules/<id>/entry.js">` **before
|
|
`</body>`**, for each **started** module.
|
|
4. Before that tag, core has published `window.__rg` (§3.2) from its own bundle. The module's
|
|
externals resolve against it.
|
|
5. Core renders **on `DOMContentLoaded`**, which is after every one of those scripts, so the routes a
|
|
module registers are present in the first render.
|
|
|
|
There is exactly one React instance and core owns it. A module that bundles its own React will
|
|
produce two copies of the hook dispatcher and fail at the first `useState`; the externals config in
|
|
§3.5 is what prevents it.
|
|
|
|
Four of those five steps carry a constraint that is easy to get wrong and impossible to notice in a
|
|
unit test. All four are normative.
|
|
|
|
**The static root is the entry's directory, never the module root.** One `express.static` over a
|
|
module root publishes its server source, its `module.json` and its schema fragment. The loader
|
|
therefore **rejects an entry sitting directly in the module root** — an entry must be in a
|
|
subdirectory — rather than leaving the rule to whoever writes the mount. The mount sits behind the
|
|
module's own state guard, so a chunk is `503` while the module is `startup_failed` and `404` while it
|
|
is `disabled`, exactly as its API routes are: the browser must not be running the client half of
|
|
something the server half has stopped serving. Anything else under `/modules` is a `404`, not the SPA
|
|
shell — answering a `<script src>` with an HTML page turns a missing file into a MIME-type refusal
|
|
with a `200` in the network tab. And because a library build emits an **unhashed** `entry.js`, chunks
|
|
are served `Cache-Control: no-cache`: revalidation is what stops an upgraded module serving
|
|
yesterday's code out of the disk cache.
|
|
|
|
**The injection point is `</body>`, and that is a contract, not a formatting choice.** Module scripts
|
|
are deferred and execute in **document order**, so core's bundle — which publishes `window.__rg` —
|
|
has to come first or every import in every module chunk resolves against `undefined`. Injecting into
|
|
`</head>` happens to work today only because Vite hoists core's entry script into `<head>`; that is a
|
|
bundler's emit decision, and if it ever changed, every module in the wild would break with nothing in
|
|
core having been edited. Last in the body is after core's script wherever core's script is.
|
|
|
|
**Core's render waits for `DOMContentLoaded`, and the readyState check is `'complete'`, not
|
|
`'loading'`.** A deferred script runs *after* the document is parsed, so by the time core's bundle
|
|
executes `document.readyState` is already `'interactive'` and `DOMContentLoaded` has not fired yet.
|
|
A `readyState === 'loading'` test therefore mounts immediately, before any module chunk has
|
|
evaluated, and a module's routes are missing from the first render — which is indistinguishable from
|
|
a module that failed to load: its URL falls through to core's catch-all and redirects home. This was
|
|
found by loading a real chunk in a browser, not by a test, and it is why PR 7's verification includes
|
|
one (§7.7).
|
|
|
|
### 3.2 `window.__rg`
|
|
|
|
Populated by core's `main.jsx` **before** it renders, and frozen afterwards.
|
|
|
|
```js
|
|
window.__rg = {
|
|
version: '1.0.0', // MODULE_API_VERSION — the same number as the server's
|
|
react, // the React namespace
|
|
reactDom, // react-dom/client
|
|
router, // react-router-dom namespace
|
|
jsxRuntime, // react/jsx-runtime — see below
|
|
registry, // §3.3
|
|
ui, // §3.4
|
|
api, // §3.5
|
|
}
|
|
```
|
|
|
|
`jsxRuntime` is not decoration. A module's bundler compiles every `.jsx` file to imports from
|
|
`react/jsx-runtime` under the modern automatic runtime, and those have to resolve to *core's* React
|
|
like every other import. Without it on the global a module would have to build with
|
|
`jsxRuntime: 'classic'`; with it, the module uses the default its tooling already assumes.
|
|
|
|
A module entry checks `window.__rg.version` against its own `coreApi` range and refuses to register
|
|
on a mismatch, logging once — the client-side twin of §1.1, and the reason `version` is here at all.
|
|
|
|
### 3.3 `registry` — what the module registers
|
|
|
|
```js
|
|
registry.registerRoutes(id, { public: [...], admin: [...], player: [...] })
|
|
registry.registerNav(id, { area, items })
|
|
registry.registerFeatureProvider(id, namespace, hook)
|
|
registry.registerExtension(id, slot, Component) // 1.2.0 — §3.7
|
|
```
|
|
|
|
**`registerRoutes`** — arrays of `{ path, element, gate? }`. Paths are relative to the module's
|
|
namespace and core prefixes them (`MODULE_SYSTEM.md` §2.8):
|
|
|
|
| Area | Rendered at | Wrapped in |
|
|
| --- | --- | --- |
|
|
| `public` | `/<id>/<path>` | `MaintenanceGate` |
|
|
| `admin` | `/admin/<id>/<path>` | `RequireAuth` + `AdminLayout` |
|
|
| `player` | `/player/<id>/<path>` | `RequirePlayer` + `PlayerPortalLayout` |
|
|
|
|
`gate` is an optional `{ roles: [...] }`, applied by core as the existing `RoleGate`. A module cannot
|
|
supply its own auth wrapper — that is the one place where the client boundary is load-bearing, since
|
|
the sidebar and the route table must agree about who may see what.
|
|
|
|
`App.jsx` stops being a flat static table and becomes core's routes plus
|
|
`registry.routesFor(area)`. Registration happens at entry-script evaluation, which is before
|
|
`createRoot().render()`, so nothing renders against a half-populated registry.
|
|
|
|
**`registerNav`** — items interleave into *core* groups (`MODULE_SYSTEM.md` §1.4):
|
|
|
|
```js
|
|
registry.registerNav('uo', {
|
|
area: 'admin',
|
|
items: [
|
|
{ label: 'Shard ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30,
|
|
roles: ['admin', 'moderator'] },
|
|
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 10 },
|
|
],
|
|
})
|
|
```
|
|
|
|
`group` names an existing core group; an unknown group name appends a new group at the end rather
|
|
than dropping the item. `order` sorts within the group, core items keeping their current positions.
|
|
`feature` names a flag resolved by the provider below. **`icon`** (1.3.0) is a component core renders
|
|
exactly as it renders its own rows' icons.
|
|
|
|
**Core supplies no fallback icon, and both layouts tolerate a row without one.** A module that omits
|
|
`icon` gets no icon, the same as a core row that omits it — inventing one would be core making a
|
|
presentation choice for content it knows nothing about. The field exists because without it the six
|
|
UO rows would have extracted as the only text-only entries in a sidebar where every other row has a
|
|
glyph, which reads as breakage rather than as a design; `icon` was already among the fields an
|
|
override may **not** touch, so the concept predates a module being able to send one.
|
|
|
|
A module should match the nav its row lands in rather than ship one glyph for everywhere: the admin
|
|
sidebar draws at 18px with a 1.6 stroke and the player portal at 16px with a 2. That is presentation,
|
|
deliberately not in the kit — putting core's icon frame in the contract would make changing it a
|
|
major bump.
|
|
|
|
**The tolerance is newer than the field.** `PlayerPortalLayout` rendered `<n.icon />` unguarded,
|
|
which was correct for as long as every row in it was core's own and had one; the first module row
|
|
without an icon blanked the entire portal with React error #130. Both layouts guard now, and
|
|
module-uo's `registration.test.js` asserts an icon on every admin and player row it registers —
|
|
belt and braces, because the failure is invisible to a DOM-less test runner and the smoke caught it
|
|
only by chance of registering that row last.
|
|
|
|
Six details settled when this was built (Phase 2 PR 8, `client/src/modules/nav.js`):
|
|
|
|
- **`order` on a flat nav is a position among core's rows**, which are keyed by their index; an
|
|
explicit `order` beats a core row that merely sits at that index. A row with **no** `order`
|
|
appends after the coded rows rather than defaulting to 0 — otherwise "I didn't ask for a
|
|
position" would mean "put me first", which is the one place a module could take over the header
|
|
without asking for anything.
|
|
- **A row with no `group` on the admin sidebar gets a trailing untitled group of its own**, not a
|
|
place in one of core's untitled groups. Those are Dashboard at the top and Account at the bottom;
|
|
a module page belongs beside neither, and core does not invent a display title out of a module id.
|
|
- **A group a module created is itself a legal override destination.** It falls out of building the
|
|
destination set from the merged base nav, and is recorded so it is not mistaken for an accident.
|
|
- **A row whose `to` collides with an existing row is dropped, with a console warning.** `to` is the
|
|
key the override layer stores under and React renders by, so two rows sharing one would give an
|
|
admin a single editor row that silently moves both. Core's row wins, since that is the one any
|
|
stored override was written against.
|
|
- **`feature` is not public-area-only.** An earlier draft of this section said it was, on the
|
|
grounds that core's admin and player navs carry no flags. They still do not — but a module row
|
|
that declares a gate and has it silently ignored is a trap, so the gate is applied in all three
|
|
areas and the field means one thing everywhere.
|
|
- **The interleave happens before the override merge, and that ordering is load-bearing.** The merge
|
|
is keyed by `to` and drops any key its base array does not declare, so module rows appended
|
|
afterwards would be unorderable, unrelabellable and unhideable — a visible regression for every
|
|
operator who has ever edited a nav, the day the UO rows leave core.
|
|
|
|
Moderator confinement, `MOD_PATHS` in `AdminLayout.jsx:109` — a hardcoded allowlist of five paths —
|
|
becomes a computation over each item's `roles`, so moderator visibility follows from the
|
|
registration instead of from a second list that has to be kept in sync. It moved to
|
|
`client/src/lib/adminNav.js`, plain JS so the test runner can reach it, along with the redirect that
|
|
confines a moderator who deep-links. **The redirect derives from the base nav, never the
|
|
override-merged one**: an override is presentation and must not move an authorization boundary in
|
|
either direction — hiding a row must not also bar someone from the page, and un-hiding one must not
|
|
admit them to it.
|
|
|
|
Deriving it changed what a moderator sees, in both cases toward what the server already permitted:
|
|
**Dashboard**, whose `roles` had always named moderator while `MOD_PATHS` omitted it, and **My
|
|
Characters**, which is ungated self-service. It also fixed a defect the two lists had between them —
|
|
`/admin/houses` was on the sidebar and not in the redirect's own third list, so a moderator clicking
|
|
Houses in their own nav was bounced back to Moderation.
|
|
|
|
The pipeline is unchanged from `THEMING_AND_NAV.md`, with one new first step:
|
|
|
|
> registered defaults (core **+ modules**) → admin overrides → role/feature filtering → rendered nav
|
|
|
|
An earlier draft of this line had the last two the other way round. The filter runs **last** and
|
|
that is deliberate — it is what keeps it a boundary an override cannot cross (`THEMING_AND_NAV.md`
|
|
§7), and both layouts have always been written that way.
|
|
|
|
**`registerFeatureProvider`** — core keeps a generic flag context; the module supplies the hook that
|
|
fills its namespace (`useShardFeatures` for `uo`). With no module installed the filter is a correct
|
|
no-op, because no core nav item carries a `feature` today.
|
|
|
|
**The namespace comes from the registration, not from the string.** A row's `feature` is resolved by
|
|
the provider its own module registered, so a module author writes `feature: 'status'` exactly as it
|
|
reads today: nothing parses a prefix, and a typo'd namespace is not a thing that can exist. Core's
|
|
own rows carry no `moduleId` and resolve against the owner id **`core`** — which is what core
|
|
registers `useShardFlags` under (`main.jsx`), the client twin of the server's `registries.registerCore()`.
|
|
So the ten shard-gated rows in the public header already run through the module seam rather than
|
|
beside it, and Phase 3 deletes core's registration instead of rewriting the header.
|
|
|
|
A provider hook returns **a Set-like of the flags this viewer may see, or `null`** while the answer
|
|
is in flight. Every unknown — no provider, a null answer, a provider that returned something without
|
|
a `has`, a malformed row — **shows the link**. This is presentation and the server is the gate, so a
|
|
UI mistake that hides a page from someone entitled to it is worse in every case than one that shows
|
|
a link which then 403s.
|
|
|
|
Core calls every registered provider's hook unconditionally, in a fixed order, at the top of the
|
|
context component. That is legal because the rules of hooks require the same hooks in the same order
|
|
on every render of a component, not a statically known list: registration completes before the first
|
|
render (§3.1), nothing unregisters, and the provider list is snapshotted per component instance
|
|
anyway. `registry.featureProviders()` is a module export and deliberately **not** a member of the
|
|
`registry` object handed to modules — a module asks for a namespace it knows the name of, and has no
|
|
business enumerating what everyone else registered.
|
|
|
|
### 3.4 `ui` — the shared component kit
|
|
|
|
**This is the largest addition Phase 1 makes to the plan, and it is not optional** (§6.2; approved
|
|
2026-08-10). The atlas
|
|
pages alone import five core modules that are not React and not the router: `PublicLayout`,
|
|
`PageHeader`, `Loading` / `ErrorState` / `EmptyState`, and `useAsync`. Without a shared kit a module
|
|
either reaches into core's tree (violating the zero-import rule) or ships its own copies, which
|
|
means a module page that does not look like the site it is installed in — and drifts further every
|
|
time core's layout changes.
|
|
|
|
The kit is **curated and closed**, not a re-export of `components/`:
|
|
|
|
| Export | From | Why it is in the kit |
|
|
| --- | --- | --- |
|
|
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome **and**, via `shell`, the page body; a module page without it is a bare page |
|
|
| `PageHeader` | `components/PageHeader.jsx` | title/subtitle furniture |
|
|
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
|
|
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
|
|
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
|
|
| `Slot` *(1.6.0)* | `modules/Slot.jsx` | renders a place this module declared for core to fill (§3.7a) |
|
|
|
|
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
|
|
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing
|
|
props is a **major** one, because that breaks a call already written. Adding an **optional** prop is
|
|
minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth
|
|
anything.
|
|
|
|
The kit is those **nine exports** — six rows, because `PageState` contributes three. An earlier
|
|
draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are
|
|
plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a
|
|
core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case
|
|
this versioning exists for.
|
|
|
|
#### `PublicLayout` gives you the chrome; `shell` gives you the body
|
|
|
|
`PublicLayout` renders the header, the footer and the `.page` flex column. It does **not**, by
|
|
default, render the body wrapper that every core public page writes for itself:
|
|
|
|
```jsx
|
|
<PublicLayout shell="narrow"> // 'narrow' (760px) · 'mid' (880px) · 'wide' (1280px)
|
|
<PageHeader title="Our servers" />
|
|
…
|
|
</PublicLayout>
|
|
```
|
|
|
|
Without a `shell`, content renders full-bleed from x=0 with no vertical padding, and the footer rides
|
|
up underneath it instead of sitting at the bottom of the viewport. That last part is the one worth
|
|
knowing, because it looks like a CSS bug in the module rather than a missing wrapper: the footer is
|
|
pushed down by `flex: 1` on the body element, so a page with no body element has nothing pushing it.
|
|
|
|
**Name a width, never a class.** The classes those widths map to are `theme.css`'s and are *not*
|
|
contract — the theming workstream owns that file and must stay free to rename them. A module that
|
|
hardcoded `className="shell-narrow page-body"` would look right today and break silently on a rename,
|
|
with nothing failing anywhere. An unrecognised width falls back to `narrow` rather than to no
|
|
wrapper at all, because a page at the wrong width still looks like the site and a page with no
|
|
wrapper does not.
|
|
|
|
This exists because the Integration Kit's acceptance run
|
|
([`../modules/kit-acceptance.md`](../modules/kit-acceptance.md)) proved a module author cannot
|
|
discover the wrapper: the kit hands them `PublicLayout` and describes it as the public chrome, which
|
|
is true and was not enough. Core's own pages keep writing their wrapper by hand and are unaffected.
|
|
|
|
### 3.5 `api` — the request primitive
|
|
|
|
`client/src/api/client.js` is one 518-line object, and it already carries module namespaces:
|
|
`api.atlas` (line 196) and `api.shard` are UO bindings living in core's client. They move out with
|
|
the module.
|
|
|
|
Core exposes the primitive, not the object:
|
|
|
|
```js
|
|
window.__rg.api = { request, ApiError, BASE } // request(path, { method, body, headers, raw })
|
|
```
|
|
|
|
`BASE` is `/api/v1`, and it was specified here from the first draft while `shared.js` published only
|
|
the first two — nothing needed it until slice 3, when a module first had to build an **EventSource**
|
|
URL. `request` is fetch-only, so an SSE subscriber constructs its own; the alternative is a module
|
|
hardcoding `/api/v1`, which asserts something about where core mounts its API that core has never
|
|
promised to keep. Published as of 1.3.0.
|
|
|
|
`request` is `client.js`'s existing `req` — same-origin `/api/v1`, `credentials: 'include'`, JSON
|
|
in/out, throwing `ApiError(status, message, body)`. A module builds its own namespace over it and
|
|
owns the paths it calls, which is correct: it owns the routes at the other end.
|
|
|
|
### 3.6 Vite library-mode build
|
|
|
|
The module's `vite.config.js`, and the shared-dependency aliases are the whole contract:
|
|
|
|
```js
|
|
export default defineConfig({
|
|
plugins: [react(), assertSharedNotBundled()],
|
|
resolve: {
|
|
// ARRAY form with ANCHORED regexes. The object form does PREFIX matching, so
|
|
// a `react` key silently also rewrites `react/jsx-runtime`.
|
|
alias: [
|
|
{ find: /^react$/, replacement: shim('react') },
|
|
{ find: /^react\/jsx-runtime$/, replacement: shim('jsx-runtime') },
|
|
{ find: /^react\/jsx-dev-runtime$/, replacement: shim('jsx-runtime') },
|
|
{ find: /^react-dom$/, replacement: shim('react-dom') },
|
|
{ find: /^react-dom\/client$/, replacement: shim('react-dom') },
|
|
{ find: /^react-router-dom$/, replacement: shim('react-router-dom') },
|
|
],
|
|
},
|
|
build: {
|
|
lib: { entry: 'src/entry.jsx', formats: ['es'], fileName: () => 'entry.js' },
|
|
outDir: 'dist',
|
|
modulePreload: { polyfill: false }, // same reason as core: no inline bootstrap under CSP
|
|
rollupOptions: { external: [] }, // deliberately empty — see below
|
|
},
|
|
})
|
|
```
|
|
|
|
Each aliased specifier resolves to a two-line shim that re-exports from the global:
|
|
|
|
```js
|
|
// src/shim/react.js
|
|
export default window.__rg.react
|
|
export const { useState, useEffect, useMemo, useCallback, useRef, createElement, Fragment } = window.__rg.react
|
|
```
|
|
|
|
**Corrected 2026-08-11, Phase 3 slice 0: `external` and the aliases do not compose, and this section
|
|
used to show both.** Rollup asks `external` *before* Vite's alias resolver runs, so a specifier
|
|
listed there is marked external and never aliased. The chunk then emits bare `import 'react'`
|
|
specifiers, which the browser cannot resolve without an import map — and CSP forbids the inline
|
|
`<script type="importmap">` that would provide one (`MODULE_SYSTEM.md` §1.14). Slice 0 shipped with
|
|
both, built cleanly, and emitted exactly that chunk. So: **alias only, and `external` empty.**
|
|
|
|
What `external` was there to guard is real — an alias that misses means a second React welded into
|
|
the chunk, which loads fine and then throws about an invalid hook call somewhere unrelated. That is
|
|
guarded instead by a **resolution-time plugin that fails the build if a shared dependency resolves
|
|
into `node_modules`**. Two things about it are contract, because both were wrong first:
|
|
|
|
- **It hooks `transform`, not `load`.** `load` is first-wins, so an earlier plugin returning the
|
|
module's contents means the guard is never called for it. Written against `load` it sat in the
|
|
build doing nothing, and a deliberately-broken alias produced a 24 kB chunk with react-router
|
|
bundled and a green build.
|
|
- **Its forbidden-package list is stated, not derived from the alias list.** Deriving it "so the two
|
|
cannot disagree" means deleting an alias also deletes the guard against what that alias prevented
|
|
— which is exactly when it is needed. What may not be bundled is a fact about `window.__rg`; a
|
|
test asserts the aliases stay inside it.
|
|
|
|
The module's own boundary checks are `scripts/checkImports.js` (§5.1) and `scripts/checkExternals.js`,
|
|
which asks the **built chunk** whether any bare specifier survived. That question cannot be asked of
|
|
source: `import { useState } from 'react'` is correct in every file, and which React it becomes is
|
|
decided here.
|
|
|
|
**This is the highest-risk mechanical detail in the whole plan.** The Phase 1 spike proved the
|
|
approach; slice 0 proved the configuration, in a browser, under the enforced `script-src 'self'`,
|
|
by checking each imported binding is **identity-equal** to the one core published.
|
|
|
|
### 3.7 Extension slots — module content inside a core page
|
|
|
|
Added in **1.2.0** (settled 2026-08-11), third slot added in **1.3.0**. The client twin of §2.4's
|
|
`registerExtension`, and the same
|
|
rule in both halves: **core declares a slot, only core may declare one, and at most one module may
|
|
fill it.**
|
|
|
|
```js
|
|
registry.registerExtension(id, slot, Component)
|
|
```
|
|
|
|
Core renders a slot with `<Slot name="…" {...props} />`, which renders the filling component with
|
|
those props — or **nothing at all** when the slot is unfilled. An instance with no module installed
|
|
therefore renders exactly what it renders today, which is the same "untouched path" guarantee
|
|
`withModuleNav` makes for nav.
|
|
|
|
**Core decorates a slot with `<Slot wrap>`, never by asking whether it is filled.** `wrap` is called
|
|
with the extension's element and rendered *inside* the boundary, so core's own markup around an
|
|
extension — a separator, a heading, a rule — shares the extension's fate:
|
|
|
|
```jsx
|
|
<Slot name="site.footer.status" linkStyle={LINK_STYLE} wrap={(link) => <> · {link}</>} />
|
|
```
|
|
|
|
There is deliberately no `hasExtension` for a layout to branch on. Branching is right about the
|
|
unfilled case and wrong about the failed one: the slot *is* filled, so the separator renders, and
|
|
the component then throws into the boundary and leaves the separator behind on its own. Found in a
|
|
browser with this exact footer separator, which is the only place it could have been found.
|
|
|
|
The slots:
|
|
|
|
| Slot | Rendered in | Props core passes |
|
|
| --- | --- | --- |
|
|
| `site.footer.status` | `components/SiteFooter.jsx`, in the info row | `linkStyle` — the row's own link styling |
|
|
| `admin.users.detail` | `routes/admin/views/UserDetail.jsx`, below the security panel | `userId` |
|
|
| `player.invite.accepted` (1.3.0) | `routes/player/AcceptInvite.jsx`, after an invite is accepted | `onDone` — send the invitee on to the portal |
|
|
|
|
**A slot is named for a PLACE, never for a meaning.** `site.footer.status` is "the status-ish spot in
|
|
the footer", not a declaration that core knows what a game server's status is: core supplies the
|
|
position and the styling, and the module owns the label, the target, the data and whether it renders
|
|
anything at all. This is the whole point of the mechanism in a game-agnostic core — the moment core
|
|
types a slot by its content, it has re-acquired the semantics Phase 3 exists to remove, and the next
|
|
module wanting that spot for something else needs a second mechanism.
|
|
|
|
**`admin.users.detail` is deliberately the same name as the server slot** (§2.4). One resource, one
|
|
extension point, two halves — a module that adds routes under `/api/v1/admin/users/:id` is the module
|
|
that has something to show on that page, and giving the two halves one name means an operator reading
|
|
either side sees the same word.
|
|
|
|
`userId` is the only prop that slot gets, and not `scope`: core's `api.admin.userShard(id)` is a UO
|
|
binding that leaves core in the client half of Phase 3, so a slot that handed it over would hand over
|
|
something core is about to delete. The extension builds its own client for the routes it registered
|
|
at the other end, which is §3.5's rule applied to a slot.
|
|
|
|
**`player.invite.accepted` is the one slot whose emptiness core reads.** Core rendered a UO
|
|
game-account step on that page until slice 3 — it read a `gameAccountSignup` flag out of its own
|
|
settings and posted to a shard route — and an invite is a core concept that staff receive too, so the
|
|
page stays and its optional step becomes a slot. With the slot unfilled there is no screen to show at
|
|
all, so core navigates straight on; with it filled, core renders its shell, the slot, and a "skip"
|
|
control *outside* the boundary, because an extension that throws must not take the way out with it.
|
|
|
|
That makes `extensionFor` legitimate for a core page to call — once, and only for this reason. The
|
|
rule is not "never ask whether a slot is filled", it is **ask only when the answer changes control
|
|
flow, never when it changes decoration**; decoration goes inside `<Slot wrap>`. `AcceptInvite` is the
|
|
only caller in core, and the difference between the two cases is exactly the footer-separator bug
|
|
above.
|
|
|
|
Core's subtitle for that screen says nothing about what the step is. Naming it would be core
|
|
describing content it does not own, and there is no wording that stays true for the next game.
|
|
**Whether the step should appear at all is the module's decision too** — it is made from a setting
|
|
core no longer reads, so the filling component either renders or calls `onDone` itself.
|
|
|
|
**Errors are contained.** Core renders a filled slot inside an error boundary: a component that
|
|
throws costs its own section and a console error, never core's page. That asymmetry is deliberate and
|
|
is where the client differs from the server — a module route that throws costs the module's own page,
|
|
but an extension throws *inside* core's, and the whole reason core keeps ownership of the page is
|
|
that it stays usable.
|
|
|
|
**Malformed registrations throw, and that matches the server.** An unknown slot name, a non-function
|
|
component, or a second module filling a filled slot all throw at the call, exactly as
|
|
`checkExtensionShape` does server-side. This is the one place the client registry is not fail-open:
|
|
a dropped nav row costs a link the user can reach another way, while a silently dropped extension is
|
|
invisible to everyone including its author, and the ordering guarantee below means a throw here is
|
|
always a programming error and never a race.
|
|
|
|
**Declaration always precedes filling, structurally.** Core declares its slots in `main.jsx`, which
|
|
runs in core's own bundle; module chunks are deferred scripts injected after it (§3.1). So a module
|
|
can never fill a slot that has not been declared yet, and "unknown slot" always means a typo or a
|
|
version skew rather than a load-order accident.
|
|
|
|
**Core fills its own slots through the same seam** — `registerExtension('core', …)` in `main.jsx`,
|
|
the client twin of the server's `registries.registerCore()` and the same trick `useShardFlags`
|
|
already uses for the feature seam. It means the mechanism is exercised by core's own content from the
|
|
moment it lands, and the extraction becomes a deletion rather than a rewrite made under extraction
|
|
pressure.
|
|
|
|
**While core fills a slot, no module can** — first fill wins, and core registers first. In 1.2.0 that
|
|
means both slots are occupied and a module's fill is rejected, loudly, naming `core`. This is
|
|
scaffolding, not the steady state: core's two fills and the two files behind them are deleted by the
|
|
client half of Phase 3, in the same change that registers the module's. Neither slot is one core
|
|
intends to fill permanently, and a slot core *does* intend to fill is a slot that should not exist.
|
|
|
|
`declareSlot` and `extensionFor` are module exports of `modules/registry.js` and deliberately **not**
|
|
members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
|
|
declaring is core's, and so is reading back who filled what.
|
|
|
|
### 3.7a Inverted slots — CORE content inside a MODULE's page *(1.6.0)*
|
|
|
|
The mirror of §3.7, added for Teams. §3.7 assumes core owns the page and a module contributes to it,
|
|
which is right for the footer and the admin user detail. This is the other shape, and the case that
|
|
forced it is worth stating because it will recur:
|
|
|
|
> **A core primitive whose vocabulary core does not own.** Teams are core's — core owns the tables,
|
|
> the reconciler, the access resolver and the activity feed — but core has no word for one. A UO shard
|
|
> calls them guilds; the next game will call them clans. A core-rendered `/teams` page would publish a
|
|
> noun core invented, beside the module's own page for the same thing. So the **page** is the
|
|
> module's, and the parts core cannot hand over — here the activity feed, whose public/members split
|
|
> only core can resolve — are contributed to it.
|
|
|
|
```js
|
|
// In the module's entry chunk, at registration time. The second argument names
|
|
// which of CORE's contributions belongs in that place:
|
|
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
|
|
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
|
|
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
|
|
|
|
// In the module's page, from the UI kit:
|
|
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
|
|
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
|
|
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
|
|
```
|
|
|
|
**Core offers a CONTRIBUTION; it never names a slot.** This is the part a second game depends on, and
|
|
the first cut of 1.6.0 had it the other way round — core filled the three literal names above, which
|
|
worked for `module-uo` and silently did nothing for anybody else: a module declaring `clan.detail`
|
|
under its own id got an empty page and no error, because "a fill for a slot nobody declared is not an
|
|
error" is exactly the rule that makes an unknown name invisible. It also put a module identifier
|
|
inside core, in three string literals `scripts/checkModuleIdentifiers.js` masks by construction and
|
|
could never have caught (§5.2). Corrected inside 1.6.0, before it reached `main`.
|
|
|
|
So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there:
|
|
|
|
| Contribution *(1.6.0)* | What core puts in the slot | Why it is core's |
|
|
| --- | --- | --- |
|
|
| `team.activity` | the Team activity feed | only core can resolve the public/members split on it |
|
|
| `team.forum` | the Team forum panel | membership and manual grants are core's rules |
|
|
| `team.notify` | the per-Team notification control | core resolves whether the viewer is in the Team |
|
|
|
|
`options.core` is **optional** — a module may declare a place it fills itself, or one it is keeping
|
|
empty for now. Asking for a contribution core does not offer **throws at the declaration**, and that
|
|
asymmetry with an unfilled slot is deliberate: core's catalogue is fixed at build time and the
|
|
module's `coreApi` range has already been checked, so an unknown contribution is always a typo or a
|
|
version skew, and the alternative failure is a page that renders empty forever with nothing logged.
|
|
**Adding a contribution is a minor bump**; removing one is major.
|
|
|
|
More than one slot may ask for the same contribution and each gets it. Core has no reason to care how
|
|
many places a module wants its feed in, and refusing the second would be core making a layout decision
|
|
on a page it does not own.
|
|
|
|
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
|
|
guild page — core fills them with the Team notification control, the activity feed and the Team forum
|
|
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
|
|
decision about where each of its contributions sits, on a page the module owns, and the module does
|
|
use that freedom: the notification control goes **above** the roster because muting is an action *on*
|
|
the page, and the other two go below it because they are content *in* it. Separate slots also keep
|
|
them independent: a deployment with the forum switched off renders the other two unchanged.
|
|
|
|
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
|
|
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner
|
|
readable at the fill site.
|
|
|
|
**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's
|
|
bundle evaluates before every module chunk (§3.1), so at the moment core would like to fill one of
|
|
these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and
|
|
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
|
|
render.
|
|
|
|
**A contribution nothing asks for is a no-op, never an error.** No game module is installed, which is
|
|
the ordinary case on any deployment — the exact mirror of an unfilled slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
|
|
name is always a typo or a version skew, because core declares before any module can name one.
|
|
|
|
**First fill still wins**, so a module that fills its own declared slot keeps it and core's fill is
|
|
skipped. That is deliberate: the module owns the page.
|
|
|
|
**`Slot` is the ninth member of the UI kit** (§3.4) for this. A module could not render one of these
|
|
otherwise, and reimplementing it would mean a second error boundary with different behaviour — which
|
|
matters more here than anywhere else in the kit, because the thing being contained is *core's* content
|
|
failing inside the *module's* page.
|
|
|
|
`declareModuleSlot` is on the `registry` object handed to modules. `offerCoreFill`, `applyCoreFills`
|
|
and `CORE_CONTRIBUTIONS` are not: offering into one of these is core's, exactly as declaring a §3.7
|
|
slot is.
|
|
|
|
---
|
|
|
|
## Part 4 — The loader's obligations
|
|
|
|
### 4.1 Synchronous, filesystem-sourced
|
|
|
|
`app.js` scans `modules/*/module.json` with `fs.readdirSync` at require time and mounts what it
|
|
finds (`MODULE_SYSTEM.md` §1.12). The database is not consulted. `MODULES_DIR` defaults to
|
|
`<repo>/modules` and is overridable by env for tests and for the Docker mount. Under Compose it is
|
|
set to `/app/modules`, where `./modules` is bind-mounted read-write (`MODULE_SYSTEM.md` §2.5); the
|
|
image itself carries that directory empty and owned by the container user, and `.dockerignore`
|
|
excludes any local one so a module can never be baked in.
|
|
|
|
**A missing modules directory is not an error.** The scan catches and returns, because "no modules
|
|
installed" is the normal state of bare core and the loader must not make the mount mandatory to boot.
|
|
|
|
**The trigger is one explicit call, and there is no lazy self-scan** (§7.6). `app.js` calls
|
|
|
|
```js
|
|
modules.load({ public: publicRouter, admin: adminRouter, player: playerRouter })
|
|
```
|
|
|
|
exactly once, and every accessor throws until it has run rather than answering with an empty list —
|
|
"no modules installed" is a real state, and a caller must not be handed it by accident. Its position
|
|
in `app.js` is load-bearing in both directions: **after** `app.use('/api', apiRouter)`, so every core
|
|
prefix is already on the tier routers when §4.3 asks them what core owns and so first-match-wins
|
|
means a module cannot shadow a core route; **before** the `/api` 404, so a module route reaches its
|
|
handler instead of the catch-all.
|
|
|
|
**Mounting is a second pass** over the modules that survived validation, not part of the scan loop.
|
|
Otherwise the first module's layers sit on the tier router while the second is being validated,
|
|
indistinguishable from core's — the second module would be told it collided with *core*, naming the
|
|
wrong culprit, and the module-versus-module check would be unreachable.
|
|
|
|
### 4.2 Order
|
|
|
|
Alphabetical by `id`, deterministically. There is no dependency resolution between modules (§2.0 of
|
|
the design of record puts it out of scope) and alphabetical order is the honest way to say so — any
|
|
other order would imply a precedence that is not being computed.
|
|
|
|
### 4.3 Validation, in this order
|
|
|
|
1. `module.json` parses; no unknown keys; `id` matches the directory.
|
|
2. `coreApi` satisfied by `MODULE_API_VERSION`.
|
|
3. Declared `mounts` prefixes are well-formed and collide with nothing.
|
|
4. Declared `extensions` slots all exist.
|
|
5. `schema`/`purge` files exist and are readable; table names are namespaced or allowlisted.
|
|
6. `require(server)` succeeds and exports a function.
|
|
7. `register(ctx, api)` returns without throwing, and registers exactly what `module.json` declared.
|
|
|
|
A failure at any step is that module's failure and nobody else's.
|
|
|
|
**Step 3 asks the live tier routers, not a list.** Whether core owns a prefix is answered by probing
|
|
the tier router's own stack with express's `layer.match()`, skipping root-mounted (`fast_slash`)
|
|
layers — `public/index.js` ends with `use('/', siteRouter)` and `admin/index.js` with the dashboard
|
|
router, and both match every path, so counting them would report every prefix as taken and no module
|
|
could ever mount. A hardcoded prefix table was tried in the spike and was already one prefix stale
|
|
when it was written; deriving it means the check cannot drift the first time core adds a capability
|
|
router, and needs no second declaration of the mount table.
|
|
|
|
### 4.4 `startup_failed` is a state, not a crash
|
|
|
|
Per `MODULE_SYSTEM.md` §2.4, the loader try/catches the **entire** lifecycle — require, validation,
|
|
registration, schema replay, `onBoot` — and any failure marks the module `startup_failed` with the
|
|
reason recorded in `installed_modules`, visible in the admin panel, recoverable without shell
|
|
access. The site comes up.
|
|
|
|
Two sub-cases differ, and the difference matters:
|
|
|
|
| Failure before routes are mounted | Failure after (schema, `onBoot`) |
|
|
| --- | --- |
|
|
| routes and nav are simply absent | routes stay mounted; the dispatch guard returns **503** |
|
|
|
|
The second is what keeps the URL surface deterministic and generatable: `routes.manifest.json` must
|
|
not depend on whether a module's boot hook happened to succeed on the machine that generated it.
|
|
|
|
**Every failure is recorded against the step that produced it**, in `failure_stage`, so the admin
|
|
panel can say *where* a module broke and not only what the message was. The stages are §4.3's seven
|
|
validation steps plus `boot`:
|
|
|
|
| Stage | The step that failed |
|
|
| --- | --- |
|
|
| `manifest` | `module.json` unparseable, an unknown key, a bad or mismatched `id`, no `version` |
|
|
| `core_api` | `coreApi` missing, or not satisfied by `MODULE_API_VERSION` |
|
|
| `mounts` | a malformed prefix, or one already owned by core or another module |
|
|
| `extensions` | a declared slot that does not exist |
|
|
| `schema` | a fragment breaking a §2.6 rule at load, or a statement the database rejected at replay |
|
|
| `require` | the entry point threw, or did not export a function — also a row whose directory is gone |
|
|
| `register` | `register()` threw, a claim was malformed, or what it registered ≠ what it declared |
|
|
| `boot` | `onBoot` threw |
|
|
|
|
The four steps that share one function label themselves; the rest are inferred from how far the load
|
|
had got, and an unlabelled throw is recorded against the step that was running rather than guessed
|
|
at. Every non-failing transition clears both the stage and the reason, so a running module can never
|
|
show the failure it had two boots ago.
|
|
|
|
### 4.5 The disabled guard
|
|
|
|
A module disabled in `installed_modules` is *mounted and guarded*, never unmounted — a one-line
|
|
`if (!enabled) return res.status(404)` ahead of its tier mount. Same reason: the URL surface is a
|
|
property of the filesystem, not of a database row.
|
|
|
|
---
|
|
|
|
## Part 5 — Enforcement
|
|
|
|
### 5.1 Zero internal imports (CI, module repo)
|
|
|
|
The acceptance test for the whole contract. In the module's own CI:
|
|
|
|
```
|
|
grep -rE "require\(['\"]\.\./\.\./|from ['\"]\.\./\.\./\.\./" server/ client/src/
|
|
```
|
|
|
|
— refined to "no relative path that escapes the module root", plus a check that the only bare
|
|
specifiers in the client bundle are the four declared externals. A hit fails the build.
|
|
|
|
### 5.2 Zero UO identifiers in core (CI, website repo)
|
|
|
|
Phase 3's acceptance criterion 1: no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
|
|
`modules/`, as a CI grep test rather than a review promise.
|
|
|
|
**Settled 2026-08-11: the grep reads code, not prose.** It covers four things, and each of them is a
|
|
thing a module owns:
|
|
|
|
1. **File and directory names** under `server/src/`, `server/scripts/`, `server/db/` and `client/src/`.
|
|
2. **Import and require specifiers** — the path in `require('…')` / `from '…'`.
|
|
3. **Route path literals** — the string arguments to `.get`/`.post`/`.put`/`.patch`/`.delete`/`.use`.
|
|
4. **Declared identifiers** — function, const, class and property names.
|
|
|
|
It does **not** read comments or string content generally, and that is not a loophole. Core's
|
|
marketing copy legitimately says "shard" — `About.jsx`, `Screenshots.jsx`, `SiteFooter.jsx`,
|
|
`heroLayout.js` — and a literal word grep would turn each of those into a CI failure while proving
|
|
nothing about the boundary. Worse, it would forbid a core comment from ever using the word as an
|
|
example, which is the sort of rule people work around rather than obey. The boundary this test exists
|
|
to defend is *structural*: core must not name a module's files, import them, route to them, or
|
|
declare their symbols. It can talk about them in English.
|
|
|
|
Core's UO-flavoured default copy is dealt with directly instead, as `MODULE_SYSTEM.md` §2.7.1's
|
|
slice 4 — a rewrite with its own review, not an exemption.
|
|
|
|
**Implemented in slice 4** as `scripts/checkModuleIdentifiers.js` / `npm run check:modules`, run as
|
|
the first step of the server-tests job. Four details are contract rather than implementation, because
|
|
each was wrong in a first attempt:
|
|
|
|
- **Matching is on whole WORDS.** Names are tokenised on camelCase humps and on `-`/`_`/`.`/`/`, and
|
|
compared word by word — so `shardStatus` is a hit and `defaultImage`, which contains the substring
|
|
"ultIma", is not. `uo`+`link` and `town`+`crier` match only as adjacent pairs; "link" on its own is
|
|
ordinary core vocabulary and a check that forbade it would be ignored within a week.
|
|
- **Comments and string bodies are masked in one left-to-right character walk, comments first.** A
|
|
comment contains quotes and a string contains `//`; no ordering of regexps gets both right. Masked
|
|
rather than deleted, so offsets survive and a route path literal is still findable at its own
|
|
position.
|
|
- **Grandfathering (§6.5) is exempted by an explicit list naming file, identifier and reason**, and
|
|
an entry that matches nothing **fails the build**. Core's `LEGACY_TABLE_PREFIXES` and
|
|
`LEGACY_STREAM_IDS`/`LEGACY_LEGS` are maps keyed by module id, so grandfathering cannot be written
|
|
down without naming who is grandfathered; that is the only sanctioned reason to add one.
|
|
- **The file list comes from `git ls-files`**, not a directory walk: an untracked, gitignored,
|
|
operator-supplied data file is not core's source and must not fail anyone's build.
|
|
|
|
The check has its own test suite. That is not optional here — a boundary check is written when the
|
|
boundary is already clean, so it never fires again, and nothing distinguishes "still checking" from
|
|
"quietly broken" without cases it is required to reject.
|
|
|
|
### 5.3 Zero-line route manifest diff (CI, both repos)
|
|
|
|
`npm run routes:manifest -- --check` in core; the module generates and freezes its own manifest in
|
|
its own repo, using the same script pointed at a core+module app. Phase 2 must produce a zero-line
|
|
diff in core's; Phase 3 moves the UO entries out of core's and into module-uo's, which is the one
|
|
diff the whole workstream is allowed.
|
|
|
|
**Settled 2026-08-11: `module-uo`'s CI checks core out at a pinned ref.** The module's workflow
|
|
clones `RunicGateway/website` at a ref recorded in the module repo, drops itself in as `modules/uo`,
|
|
and runs core's own `routeManifest.js`. Nothing else proves the URLs a module claims are the URLs it
|
|
actually serves — a manifest frozen by hand goes stale silently, and the failure it would have caught
|
|
is a route that moved.
|
|
|
|
**Built in slice 5, and what it does with that core is a SUBTRACTION.** The job generates the
|
|
manifest without the module and then with it; the difference is what the module serves. Filtering the
|
|
combined manifest by the module's prefixes would have answered only "what does the module serve".
|
|
Subtracting also answers **"did core lose anything"** — and a module that shadowed or displaced a
|
|
core route cannot appear as an addition anywhere, so that is the only way to see it. Three checks
|
|
come out of one diff:
|
|
|
|
1. the added routes match the module's committed `routes.manifest.json`;
|
|
2. **no route of core's was removed or changed**, which is `MODULE_SYSTEM.md` §1.2's promise;
|
|
3. every added route has an operation in `swagger-fragment.json`, and every operation is an added
|
|
route — §2.8's coverage requirement, answered against a running app rather than against a table.
|
|
|
|
The third is why this job matters beyond the manifest: everything else in a module's repo compares
|
|
two strings that live in that repo. This compares a URL the module registers against a URL a real
|
|
Express app reports serving, which is the only thing that can catch a fragment that is internally
|
|
consistent and describes nothing.
|
|
|
|
Also learned here: **copy the module into the core checkout, never symlink it.** The loader filters
|
|
its scan with `entry.isDirectory()`, which reports a link as a link and skips it silently — the
|
|
manifest then comes out with no module routes and the diff looks like a module that registered
|
|
nothing. And the client chunk must be **built before** the copy: `client.entry` is validated during
|
|
the manifest step of the scan, so a missing chunk is a load failure, not a warning.
|
|
|
|
Pinning the ref rather than tracking `edge` is what keeps this from being a source of unexplained red
|
|
Xes: core moves for reasons that have nothing to do with the module, and a bump is then a deliberate
|
|
commit that says which core the module was last proved against.
|
|
|
|
---
|
|
|
|
## Part 6 — Amendments to MODULE_SYSTEM.md
|
|
|
|
Things the survey found that the design of record gets wrong or does not cover, plus what
|
|
implementation has since amended. The first needed a decision and has one.
|
|
|
|
### 6.1 OpenAPI generation does not survive a dynamic loader — **settled: fragment merge**
|
|
|
|
`MODULE_SYSTEM.md` §1.12 treats `scripts/routeManifest.js` and `swagger/swagger.js` as the same
|
|
problem, because both require `app.js` with no database. They are not the same problem.
|
|
|
|
- **`routeManifest.js` walks the live Express stack** (`app._router.stack`, line 175). It is runtime
|
|
introspection and a filesystem-scanning loader is invisible to it in the best way: whatever got
|
|
mounted, it sees.
|
|
- **`swagger/swagger.js` is static analysis.** `swagger-autogen` is handed `routes = ['./src/app.js']`
|
|
(line 29) and *parses the source text*, following `app.use(...)` to the required file. A
|
|
`require(path.join(dir, manifest.server))` inside a `for` loop is not statically resolvable. Module
|
|
routes will be **absent from `swagger-output.json`** — silently, with no error.
|
|
|
|
That collides directly with CLAUDE.md's standing rule: *never ship a route that isn't in the OpenAPI
|
|
spec*. Three ways out:
|
|
|
|
| Option | How | Cost |
|
|
| --- | --- | --- |
|
|
| **A. Fragment merge** ✅ | Module CI runs swagger-autogen against its own `server/index.js` and ships `swagger-fragment.json` in the bundle. Core deep-merges the fragments of started modules into `/api/docs.json` at request time. | one merge helper in core (~40 lines); the module owns its own spec, which matches "one repo, one bundle" |
|
|
| B. Glob the modules dir | Core's `swagger.js` adds `modules/*/server/index.js` to `routes` when present. | core's *committed* spec then depends on which modules the developer had checked out — a spec that differs per machine |
|
|
| C. Hand-write module paths into core's spec | — | a second source of truth; drifts on the first module release |
|
|
|
|
**Decided 2026-08-10: A.** It is the only one that keeps the spec correct on an operator's box,
|
|
where core is a prebuilt image and the module arrived afterwards. The obligation this puts on a
|
|
module is §2.8; the one it puts on core is Phase 2 item 2.
|
|
|
|
### 6.1a The obligation, on each side
|
|
|
|
**Module:** a `swagger-fragment.json` in the bundle root, generated by its own CI with the same
|
|
`swagger-autogen` tooling pointed at its own entry point, carrying only `paths`, `tags` and
|
|
`components.schemas`. Its paths must be fully qualified (`/api/v1/public/atlas/creatures`), because
|
|
the module knows its own mount prefixes and core does not re-derive them. Its `components.schemas`
|
|
keys are namespaced (`UoAtlasCreature`, not `AtlasCreature`) so two modules cannot collide in the
|
|
merged spec. CI fails the module build if a route it registers has no path in its fragment — the
|
|
per-module form of "never ship a route that isn't in the spec".
|
|
|
|
**Core:** `/api/docs.json` merges the fragments of **started** modules over its own committed spec at
|
|
request time (cached, invalidated on a module state change). Merge is shallow-per-section and
|
|
**core always wins a key collision** — a module cannot redefine a core path, tag or schema by
|
|
shipping one with the same name; the collision is logged and the module's version dropped.
|
|
`swagger-output.json` itself stays exactly what core's own routes generate, so `npm run swagger`
|
|
remains reproducible on any machine regardless of what is installed.
|
|
|
|
Built as `server/swagger/docsSpec.js` (website#141). Three properties that are contract rather than
|
|
implementation, because each one is a way the obvious version is wrong:
|
|
|
|
- **The committed spec is never mutated.** It is a `require()`d JSON module, so a merge in place
|
|
would be permanent for the life of the process *and* cumulative across rebuilds — a module's paths
|
|
outliving its own uninstall. Every rebuild starts from a structural copy.
|
|
- **The Swagger UI is built per request too**, not bound once while `app.js` is still being required.
|
|
Bound at require time it would show core's routes for the life of the process while
|
|
`/api/docs.json` showed the merged set — two documents at two URLs, disagreeing.
|
|
- **A bad fragment costs that module its paths and nothing else.** Missing, unreadable or not JSON is
|
|
logged and skipped; the document still answers. That is §4.4's bargain — one module's failure is
|
|
never the site's — and a docs page that 500s is strictly worse than one missing a module's routes.
|
|
|
|
`started` only, matching `clientEntryUrls()` rather than `clientChunks()`: the document is built when
|
|
it is asked for, at which point the state is known, and documenting a module that 503s every one of
|
|
those paths sends a client somewhere it cannot go. The cache key is a new
|
|
`modules.version()` — a counter the loader bumps on every state *change*, which says nothing about
|
|
which module moved or where to.
|
|
|
|
### 6.2 The client contract is much larger than §2.1 says
|
|
|
|
§2.1 lists three client registration calls and nothing else, implying React and the router are all a
|
|
module needs. The atlas pages disprove it: `Atlas.jsx` imports `PublicLayout`, `PageHeader`,
|
|
`PageState`'s three states, `useAsync` and `api` — five core modules beyond React, on the *smallest*
|
|
UO page. Hence §3.4's curated UI kit and §3.5's request primitive. This is an addition to the
|
|
contract, not a change of direction, but it makes core's component API a versioned surface, which it
|
|
has never been before.
|
|
|
|
### 6.3 `shardVisibility` is module-owned, and core's nav depends on it
|
|
|
|
`utils/shardVisibility.js` is a UO util that provides `requireFeature` and `project` — and the atlas
|
|
routes, the spike target, are gated by it (`atlas.router.js:25`). So the spike carries
|
|
`shardVisibility` plus the `shardVisibility` and `shardLinks` models with it, not just the atlas
|
|
files. Two consequences:
|
|
|
|
- On the **server** this is fine: `requireFeature` becomes module-internal middleware, and only
|
|
`PUBLIC_KINDS` crosses the boundary — already handled by §1.8's `registerNotificationStreams`
|
|
inversion.
|
|
- During the **spike** there will be two copies of `shardVisibility` in one process (the module's,
|
|
and core's for the not-yet-extracted shard routes) with two independent 5-second caches over the
|
|
same table. Functionally identical, and a spike-only artifact that Phase 3 resolves by moving the
|
|
original. Recorded so it is not mistaken for a design flaw.
|
|
|
|
### 6.4 Two counts in §1.6 and §2.7 are off
|
|
|
|
- **27 UO tables, not 25**: 26 `shard_*` plus `uo_link_config`. §1.6 says "the 24 `shard_*` tables
|
|
plus `uo_link_config`".
|
|
- **The atlas spike is 6 routes, not 5**: `/creatures`, `/creatures/:slug`, `/regions`,
|
|
`/landmarks`, `/champions`, `/meta`.
|
|
|
|
Neither changes a decision; both are corrected here rather than left to be tripped over when the
|
|
extraction is counted against the plan.
|
|
|
|
### 6.5 Grandfathered names, and why the prefix rules survive them
|
|
|
|
§2.4 requires a module's stream ids and announce legs to carry its module id. Eight names predate the
|
|
module system and cannot take it:
|
|
|
|
| Kind | Names | Why they cannot be renamed |
|
|
| --- | --- | --- |
|
|
| Streams | `server.status`, `idoc.warning`, `champ.start`, `governor.election`, `vendor.sale`, `house.idoc`, `account.login` | stored in `notification_subs` rows; read by a shipped Android client |
|
|
| Announce leg | `towncrier` | a stored value in `announce_job_legs.leg` and the body of the retry endpoint |
|
|
|
|
They are allowed to **`uo` alone**, by an explicit per-module allowlist — the same shape and the same
|
|
reasoning as the loader's `LEGACY_TABLE_PREFIXES` for module-uo's 27 tables. Grandfathering by
|
|
allowlist rather than dropping the rule is what keeps the rule real for every module written after
|
|
this one; the alternative leaves the first name collision to be discovered by a module silently
|
|
adopting someone else's stream.
|
|
|
|
### 6.6 An extension slot is invisible to static analysis — core needs the merge too
|
|
|
|
§6.1 settled the fragment merge for *modules*. PR 4 found that core needs the identical machinery for
|
|
its **own** slot fills, one phase earlier than the plan expected.
|
|
|
|
A slot's router is created by `registries.declareSlot()` and filled later, so there is no literal
|
|
`use(require(...))` for swagger-autogen to follow. Moving the six `/admin/users/:id/shard/*` routes
|
|
behind `admin.users.detail` therefore deleted 407 lines from `swagger-output.json` — with
|
|
`Swagger-autogen: Success` and no warning. Same failure as §7.4, different cause, and it would have
|
|
shipped six undocumented core routes against CLAUDE.md's standing rule.
|
|
|
|
`npm run swagger` now has a second step (`swagger/slotSpecs.js`): for each **filled** slot, generate a
|
|
fragment by pointing swagger-autogen at that router's own file, re-root its paths at the prefix the
|
|
router actually hangs at, and merge. Two things are derived rather than written down, because a
|
|
written-down copy drifts:
|
|
|
|
- **which** slots — from `registries.filledSlots()`;
|
|
- **where** each hangs — by finding the slot's own router object in the live express stack, decoding
|
|
the mount prefixes above it with `scripts/routeManifest.js`'s own `mountPath`, so the manifest and
|
|
the spec can never disagree about what a mount decodes to.
|
|
|
|
An empty fragment is a hard build failure, because an empty fragment is exactly what the silent drop
|
|
looks like. The merge itself is `swagger/mergeSpec.js` — the ~40-line helper §6.1a already owed core
|
|
for module fragments, written here and proved against core's own slot before a module depends on it.
|
|
This is **build**-time and lands in the committed spec, because slot routes are core's; a module's
|
|
fragment is still merged at **request** time into `/api/docs.json` (§6.1a), and `swagger-output.json`
|
|
stays reproducible on any machine regardless of what is installed.
|
|
|
|
`registerExtension` therefore takes a third, **core-only** argument: the file its router is generated
|
|
from. A module needs no equivalent — it ships a prebuilt `swagger-fragment.json`, because core never
|
|
has its sources to analyse.
|
|
|
|
### 6.7 `/api/v1/public/modules` is not the client's load trigger
|
|
|
|
`MODULE_SYSTEM.md` §2.6 step 4 says "the SPA reads `/api/v1/public/modules` to learn what to load,
|
|
then registers routes, nav and its feature provider". Step 3 of the same list resolved the loading
|
|
question a different way, and §3.1.3 here is the normative version: `htmlShell.js` injects a
|
|
`<script type="module" src="/modules/<id>/entry.js">` per **started** module, so the browser is handed
|
|
the tag by the document and never fetches a URL the endpoint told it about. Nothing waits on an API
|
|
round trip to start loading, which is also why the tag can be in `<head>`.
|
|
|
|
What the endpoint is for is **feature detection**: capabilities for the SPA and the Android app, which
|
|
has no chunk to load at all. §2.9 is the shape. The two statements were only ever in tension because
|
|
§2.6 was written before the CSP constraint forced the injected-tag design; step 4 should read "the SPA
|
|
reads `/api/v1/public/modules` to feature-detect", and the registration it describes happens when the
|
|
injected chunk executes and calls `window.__rg.registry` (§3.3).
|
|
|
|
---
|
|
|
|
### 6.8 A trigger, a rule and an audience outlive the module that declared them
|
|
|
|
The forward-compat note `ENGAGEMENT.md` §7.3 asks this document to carry, and the reason it is here
|
|
rather than there: it is a rule about **the contract**, not about the engagement system.
|
|
|
|
A trigger id is namespaced by its owner and collision-checked at registration, exactly as a
|
|
notification stream is. What is *not* expressed by that is a rule or a template referring to a
|
|
trigger whose module has been uninstalled.
|
|
|
|
> `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately — no foreign key, no cascade — so
|
|
> a module can be removed and reinstalled without an operator's rules being destroyed. It is the same
|
|
> decision `announce_job_legs` took for a leg whose module is gone: *"Leave it alone: failing it would
|
|
> make the job roll up terminal on the strength of a leg that no longer exists, and reinstalling the
|
|
> module should resume it."*
|
|
>
|
|
> A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI — never as
|
|
> an error, and never auto-deleted. The same holds for a rule whose *audience* is unregistered: 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 this prevents is specific: an id that stops
|
|
> resolving must never silently become a send to a **different** set of people.
|
|
|
|
`engagement_templates.trigger_id` is the same, for the same reason, with one addition: a template
|
|
records the trigger `version` it was authored against, so a declaration that has since been bumped
|
|
produces a warning in the admin list rather than silently interpolating `undefined`.
|
|
|
|
---
|
|
|
|
## Part 7 — What the spike proved
|
|
|
|
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately
|
|
never merged** — it is the evidence, not the implementation. Phase 2 rebuilds the loader properly.
|
|
|
|
### 7.1 The exit criteria
|
|
|
|
| Criterion | Result |
|
|
| --- | --- |
|
|
| No internal-file imports from the module into core | **pass** — the module's only non-builtin requires are `ctx.express` / `ctx.validator`; the built client chunk contains **zero** bare import specifiers |
|
|
| `npm run routes:manifest` produces a zero-line diff | **pass** — `routes.manifest.json` *and* `routes.guards.json` are byte-identical with the six atlas routes now served by the module |
|
|
| The chunk loads under the enforced CSP | **pass** — `/uo/atlas` and `/uo/atlas/:slug` render from `/modules/uo/entry.js` under `script-src 'self'`, with **zero** violation reports at `/api/csp-report` and a clean console |
|
|
| Everything still passes | **pass** — 729 core tests, 81 module tests |
|
|
|
|
Also verified end to end against the real database: the schema fragment replayed after core's
|
|
(`schema ensured for module "uo"`), `onBoot` ran the atlas refresh, the module reached `started`, and
|
|
the six API URLs answered 200 unchanged at `/api/v1/public/atlas/*`.
|
|
|
|
### 7.2 One express, one React — the same rule, twice
|
|
|
|
The single biggest thing the spike changed. `MODULE_SYSTEM.md` §2.6 got the client half right — one
|
|
React, shared via a global — and said nothing about the server, where the identical problem exists
|
|
and bites harder:
|
|
|
|
- A module lives at `<repo>/modules/<id>/`. Node's resolver walks *up* from there and never reaches
|
|
`server/node_modules`, so `require('express')` inside a module **fails outright**. This was the
|
|
first error the spike hit.
|
|
- Installing express into the module would fix resolution and break something worse: two `Router`
|
|
prototypes, two sets of `instanceof` checks — and it would mean the operator running `npm install`
|
|
in a module directory, which is the build the whole plan exists to avoid.
|
|
|
|
Hence `ctx.express` and `ctx.validator`. The rule generalises: **anything shared between core and a
|
|
module is owned by core and handed over — never resolved by the module.** On the client that is
|
|
`react`, `react-dom/client`, `react-router-dom` and `react/jsx-runtime`; on the server it is
|
|
`express` and `express-validator`.
|
|
|
|
Two mechanical traps inside that, both cheap once known and both silent otherwise:
|
|
|
|
- **Vite's object-form `resolve.alias` does PREFIX matching.** A `react` key also rewrites
|
|
`react/jsx-runtime` into `src/shim/react.js/jsx-runtime`, a path that cannot exist. Use the array
|
|
form with anchored regexes (`/^react$/`).
|
|
- **`external` alone is not enough for an ESM library build.** Rollup then emits bare
|
|
`import 'react'`, which the browser cannot resolve without an import map — and CSP forbids the
|
|
inline `<script type="importmap">` that would supply one. Each shared dependency needs a two-line
|
|
alias shim that re-exports from `window.__rg`. `output.globals` does not help: it applies to
|
|
iife/umd output only.
|
|
|
|
### 7.3 §6.1 confirmed empirically, not just predicted
|
|
|
|
Regenerating the OpenAPI spec after the move deleted **361 lines** — all six atlas paths — from
|
|
`swagger-output.json`, with `Swagger-autogen: Success` and no warning of any kind. The route manifest
|
|
kept all six in the same run. That is the static-analysis-versus-runtime split of §6.1 happening for
|
|
real, and it is exactly the silent failure the fragment merge exists to prevent. Core's committed
|
|
spec is correct as regenerated — it describes core's own routes — and the six paths come back via
|
|
module-uo's fragment when Phase 2 item 2 lands.
|
|
|
|
### 7.4 The loader's failure guarantees are tested, not asserted
|
|
|
|
`server/test/moduleLoader.test.js` — 17 tests over the paths nobody exercises by hand: an entry point
|
|
that throws, a `coreApi` mismatch, an unknown manifest key, an id that disagrees with its directory,
|
|
two modules claiming one prefix, a module claiming a core prefix, registering an undeclared prefix
|
|
and declaring an unregistered one, a fragment naming a core table, an unprefixed table, a schema with
|
|
no purge, an `onBoot` that throws, an `onShutdown` that hangs and one that throws, a double
|
|
registration, and a probe asserting `ctx` exposes exactly the documented surface and is frozen.
|
|
|
|
The property under test throughout is the same: **the failing module fails alone.**
|
|
|
|
### 7.5 Spike artifacts that are NOT design
|
|
|
|
Three things in the branch are consequences of stopping at six routes, and Phase 3 removes all three.
|
|
They are recorded so nobody reads them as intended shape:
|
|
|
|
1. **Core reaches into the module twice** — `admin/shardAtlas.controller.js` and
|
|
`test/atlasController.test.js` require the module's model directly. The five admin atlas routes
|
|
live at `/admin/shard/atlas/*`, inside the `/shard` prefix core still owns, so the module cannot
|
|
take them without colliding or moving a URL. Phase 3 moves the whole `/shard` admin prefix at once
|
|
and the imports go with it.
|
|
2. **Two copies of `shardVisibility`** — the module's (as `utils/visibility.js`) and core's, for the
|
|
shard routes not yet extracted. Two five-second caches over the same table; functionally
|
|
identical. Predicted in §6.3, observed exactly as described.
|
|
3. **The module has no `swagger-fragment.json`** — §6.1a's obligation needs core's merge helper on
|
|
the other side of it, which is Phase 2.
|
|
|
|
### 7.6 A finding for Phase 2's loader — **settled: an explicit `load()`**
|
|
|
|
`scan()` was lazy — requiring the loader did not run it. That was deliberate (app.js decides when
|
|
modules are discovered) but it is a sharp edge: a caller that requires the loader and reads nothing
|
|
gets an empty, *silent* module list. It cost one confusing test failure during the spike.
|
|
|
|
**Phase 2 PR 2 made the trigger explicit** rather than scanning at require time. `app.js` calls
|
|
`modules.load(tierRouters)` once, and `list()` throws until it has. Scanning on require was the
|
|
alternative and was rejected for two reasons: the loader now needs the tier routers *handed to it*
|
|
for the §4.3 collision check, which a require-time side effect cannot receive; and it would make the
|
|
ordering constraint invisible, enforced by where a `require` sits rather than by an argument that is
|
|
missing if it is wrong.
|
|
|
|
### 7.7 The client half has to be verified in a browser — **the timing bug no test could see**
|
|
|
|
Phase 2 PR 7 built the delivery mechanism: the static mount, the injected tag, `window.__rg`, the
|
|
registry, and core's consumption of it. Everything above is unit-tested, and the tests all passed
|
|
against a build that **did not work in a browser**.
|
|
|
|
The smoke that found it is worth repeating whenever this seam changes, and it is four steps: write a
|
|
throwaway `modules/<id>/` with a hand-written ESM `entry.js` — no bundler needed, since
|
|
`window.__rg.react.createElement` is enough to render a page — point `MODULES_DIR` at it, boot the
|
|
server against the built client, and load the module's URL in a real browser with the console open.
|
|
|
|
What it caught was step 5 of §3.1: core mounted before any module chunk had evaluated, because
|
|
`document.readyState` during a deferred script is `'interactive'` and not `'loading'`. The page
|
|
redirected home — the same thing a module that failed to load does — with **no error anywhere**:
|
|
the chunk had fetched, executed, and registered its route into a registry nothing read again. No unit
|
|
test in this repo can see it. There is no DOM in the server or client test runner, and the ordering
|
|
being asserted is the browser's, not the code's.
|
|
|
|
Two smaller things the same run confirmed, both worth keeping in the loop when re-running it: the
|
|
chunk executes under the **enforced** `script-src 'self'` with no CSP report, which is the property
|
|
§3.6 called the highest-risk detail in the plan; and the shell is read **once at boot**
|
|
(`htmlShell.init`), so rebuilding the client without restarting the server serves an `index.html`
|
|
pointing at a hashed bundle that no longer exists — core never runs, `window.__rg` is undefined, and
|
|
the failure looks exactly like a contract violation in the module.
|
|
|
|
**Run it against the real module too, not only a throwaway.** A hand-written chunk proves the
|
|
delivery path; it does not prove the module. Slice 3's run — module-uo checked out into
|
|
`website/modules/uo`, the deletion branch checked out in `website/` — found two things a throwaway
|
|
never would have, and neither is visible to any test in either repo:
|
|
|
|
- **`PlayerPortalLayout` rendered `<n.icon />` unguarded.** `icon` is optional in the nav contract
|
|
and every core row in that sidebar had one, so the difference from `AdminLayout` cost nothing until
|
|
a module registered a row without — then it was React error #130 and a **blank player portal**,
|
|
not a missing glyph. Both layouts guard now.
|
|
- **A relative `MODULES_DIR` failed every module** with `client.entry escapes the module directory`,
|
|
because `resolveClient` compared an absolute resolved path against a relative directory. Running
|
|
from `server/` is what produces one, so this recipe was the thing that triggered it. `MODULES_DIR`
|
|
is resolved absolute now, and the recipe works either way.
|
|
|
|
Two operational notes for the run itself. The module's own `server/package.json` has dependencies
|
|
(`ws`), so a module copied into place needs `npm ci --omit=dev` in its `server/` before it will
|
|
`register` — the release tarball carries them, a working copy does not. And **copy the module
|
|
directory, do not symlink it**: the loader's `filter(e => e.isDirectory())` reports a Windows
|
|
junction as a symlink and skips it silently.
|