Files
docs/website/MODULE_API.md
wtclaude 6647287037 docs(events): the acceptance walk, and the three contracts it moved (Phase 16a)
Phase 16 is split into 16a (the walk), 16b (the cutover) and 16c
(runicgateway.com + .profile), because the phase as written asked for a walk
"against released artefacts" BEFORE the cutover and all three component repos
release on push to `main`. The walk therefore runs against artefacts built from
`edge` the way a release builds them, and 16b re-verifies against the real bundle.

`EVENTS_PLAN.md` gains the 16a record: the rig, all three deliberate failures
passing, the six defects, the one finding withdrawn, and what each fix was
verified against.

Three contracts move, each because the walk proved the built thing did not match
the written one:

**`link/v6.md` — a refusal does not spend its key.** Rule 2 had two cases, throw
and return, and needed a third: a handler that ran to completion and deliberately
refused did nothing, so freezing that refusal as the key's answer made a refusal
that WAITING FIXES impossible to retry past. The section now carries the case
`uo.world.save` found it with, and the rule the release rests on — do not answer
`*.error` after changing the world. `[bridge status` gains `refused=`.

**`website/MODULE_API.md` — `revert`'s `idempotencyKey` identifies a dispatch; it
is not a key to send on the undo.** The paragraph explained what the key is FOR
and never said what it is not, and `module-uo` read it the other way: every
despawn went out under the key its spawn had used, so a store that keys on the key
alone answered the undo with the DO's reply and teardown became a no-op that
reported success.

**`website/EVENTS.md` §I — the public calendar matches a run that OVERLAPS the
window.** The row promised "upcoming, live and recent" and the built route served
only the first, because it read the start instant and a live run has already
started. The default window now reaches back so "recent" has somewhere to live,
and projections are forecast from now rather than into that tail.

Pairs with `website#`, `Module-uo#` and `servuo-plugins#`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:31:06 -05:00

169 KiB

The Module API — the contract

Status: Phase 1 deliverable of 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:

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.

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.

A third joined in Phase 15, also on an envelope:

  • detail on an action's SUCCESS envelope (EVENTS.md §F). An optional object a module may answer with, carried to the run log as a step.detail line and never interpreted by core — nothing reads a key out of it in the dispatcher, the runner or the browser. It exists because a module knows things about its own verb core cannot compute and had no other way to say them: uo.item.grant reaches the players a run's participation ledger holds, and which of them missed out was reported nowhere at all. On both success shapes, like resources and participants, because await: 'human' is a success and a cue's confirm finishes the step without a second dispatch.

    Objects only, 4KB of serialised JSON, dropped rather than truncated, and anything wrong with it is dropped and logged rather than failing the step — a step that did what it was asked must not be re-run because its module's commentary was malformed, which would be a world write repeated for a log line. It is additive and optional: a module that never answers one is behaving exactly as before. Found by writing the integration kit's chapter 5 (EVENTS_PLAN.md Phase 15), whose template made the same mistake module-uo had — see §F.

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).

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: 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 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.

{
  "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.

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).

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:

{ 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.

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:

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.

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.

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).

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).

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. staffadmin 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.

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's detail is carried and never read. An optional object on either success shape, bounded at the dispatcher and written to the run log verbatim beside the action id. Core reads no key out of it — a switch on known keys anywhere in core would be core learning one module's vocabulary, which is the thing this whole contract exists to prevent. It is the answer to "what actually happened" for a verb whose answer is neither a resource nor a participant, and before Phase 15 there was no such answer: EVENTS.md §H named the member, classify() had never read one, and a module that used it wrote into nothing.
  • 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.
  • That key IDENTIFIES a dispatch; it is not a key to send on the undo. It names the command core lost the answer to, so the module can ask the game about it. Forwarding it as the outgoing key of the reverting command is a different thing entirely, and on a game whose at-most-once store keys on the key alone — as the uo-link shard's does — the undo is then recognised as a repeat of the DO and answered with the original reply. module-uo made exactly this mistake: teardown of all five world verbs was a no-op that reported success, because every despawn carried the key its spawn had gone out under. Found by the Phase 16 acceptance walk, with the ledger reading reverted and the shard still holding every object. A command that undoes needs a key of its own or none at all; a repeated undo is usually harmless by construction ("already gone" is a success), which is what makes none the right answer more often than not.
  • 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 §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 exists to hold.
  • The sidecar owns the durable copy. It is a non-blocking dumb forwarder that persists before it forwardsuo-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.errors "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.

{ "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.

Core publishes a capability list of its own, and it is deliberately not this one. Since events Phase 14a, GET /public/version — and GET /public/status, which embeds the same block — carries a capabilities array naming what CORE serves beyond the baseline every backend has. It is the same idea and the same word so that a client feature-detects one way rather than two, and a separate list because core is not a module: publishing core here as a pseudo-module would leave a client unable to tell "this backend has events" from "a module called core happens to be installed", which is exactly the distinction this endpoint exists to make. The value in core's list is in what is absent — a backend released before a capability existed omits the key entirely, which is how a client tells an older site from one that simply has nothing to show. The same rule applies to both: an unknown string is absent, and no route may be inferred from one.

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.

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

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):

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:

<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) 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:

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:

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:

// 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.

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:

<Slot name="site.footer.status" linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{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 seamregisterExtension('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.

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

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 passroutes.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 twiceadmin/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.