Phase 2 is closed, so Phase 3 needs a plan before any of it is extracted.
Four decisions, and one finding that set the first of them.
The finding: because API URLs are preserved (§1.2), a feature's server and
client halves are independent. Core's client keeps calling
/api/v1/public/shard/status after the module serves it, and a module page
calls the same URL while core still does. Nothing forces vertical slices, so
the extraction is server-first then client, sliced by feature, ten slices.
Merge order within a slice is module-uo first, then website. The loader's
ownedByCore probe stops a module LOADING while core owns its prefix, but the
module's own CI never loads it into core, so its PR merges fine beforehand --
and edge then serves the feature from core right up to the moment core drops
it, with no window where the branch is missing it outright.
Criterion 1's grep reads code, not prose: filenames, import specifiers, route
path literals and declared identifiers. Core's marketing copy legitimately
says "shard" in a dozen places and a literal word grep would have failed CI on
each while proving nothing about the boundary. That copy is rewritten in its
own slice instead, which is real work with a real review rather than an
exemption hidden in a pattern.
module-uo's CI clones core at a pinned ref to freeze its route manifest --
nothing else proves the URLs it claims are the URLs it serves -- and the
module-rust dry run lands in docs/modules/ where §2.10 already aggregates
module documentation.
Also records the measured surface (72 server files, 51 client, 32 test files),
which supersedes the Phase 0 estimate, and the one kit gap: lib/format.js is
vendored by the module rather than becoming an eighth §3.4 member.
Co-Authored-By: Claude <noreply@anthropic.com>
Records the last Phase 2 PR and closes the phase.
Amends §2.5: the mount is a bind mount of ./modules, not the named volume the
section reached for by analogy with uploads. Hand-placing a module directory is
a supported install in that same section, and a named volume routes it through
`docker cp` — the least discoverable mechanism Docker offers, for the one
install path an operator without the admin panel has.
Two things the build settled that the plan had not considered, both silent
failures rather than errors: the directory has to be tracked, because Docker
recreates a missing bind-mount source as root-owned and the container is uid
1000; and .dockerignore has to exclude it, because COPY . . would otherwise bake
a builder's checked-out module into every image — and Docker seeds a fresh named
volume from image contents, so it could have surfaced on a deployment that never
installed it.
MODULE_API §4.1 gains the concrete Compose values and states outright that a
missing modules directory is not an error, which the loader has always done and
the contract never said.
Website side: RunicGateway/website#136.
Co-Authored-By: Claude <noreply@anthropic.com>
Records Phase 2 PR 8 of the module system: MODULE_SYSTEM.md gains the PR
entry in 2.7 and the built-it notes on 1.4 and 1.5; MODULE_API.md 3.3 is
amended where building it settled something the draft left open or got wrong.
The amendments to 3.3:
- The pipeline arrow had role/feature filtering BEFORE admin overrides. The
code has always been the other way round, deliberately - the filter runs
last so it stays a boundary an override cannot cross (THEMING_AND_NAV 7).
- `feature` was documented as public-area-only. It applies in all three
areas: core's admin and player navs still carry no flags, but a module row
that declares a gate and has it silently ignored is a trap.
- How a provider is found: by the module that registered the row, not by a
prefix parsed out of the flag name. Core's own rows resolve against the
owner id `core`, which core registers useShardFlags under.
- What a provider hook returns, and that every unknown fails OPEN.
- Why calling one hook per provider in a loop is legal, and why the
enumerator is a module export rather than a member of registry.
- Six details the interleave settled: unordered rows append rather than
defaulting to 0; an ungrouped admin row gets its own trailing group rather
than joining core's; a module-created group is a legal override
destination; a colliding `to` is dropped with a warning; and why the
interleave must precede the override merge.
MOD_PATHS' replacement is recorded in both files, including the defect the
derivation fixed: the moderator redirect was a third hardcoded list that
disagreed with MOD_PATHS about /admin/houses. THEMING_AND_NAV 7's
"moderator confinement" note is amended to match.
Code: website PR 8 (client-only; 160 client tests, manifest and OpenAPI
unchanged), verified with the 7.7 browser smoke.
Co-Authored-By: Claude <noreply@anthropic.com>
MODULE_API.md
- 3.1 gains the four normative constraints PR 7 settled: the static root is
the entry's directory (an entry in the module root is rejected), the mount
sits behind the module's state guard with no-cache, the script tag is
injected before </body> so core's bundle runs first, and core renders on
DOMContentLoaded with a readyState === 'complete' check
- 2.1: client.entry must be in a subdirectory; present-but-empty is rejected
- 3.4: AdminPage struck from the UI kit — core has no such component
- 7.7 (new): the browser smoke, and the timing bug no test in this repo can
see. Every test passed against a build that did not work in a browser
MODULE_SYSTEM.md
- 2.6 step 3 amended to the </body> injection point
- 2.7 records PR 7's four decisions and what verified them; 1-7 of 9 done
BACKEND_DESIGN.md
- /modules is the fourth filesystem-conditional static mount outside the
route manifest, with its root, guard and cache policy stated
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2, PR 6 of MODULE_SYSTEM.md 2.7 — GET /api/v1/public/modules.
MODULE_API.md gains 2.9, the normative shape: four fields (id, name, version,
capabilities) in scan order, and what is deliberately absent. Only started
modules appear, so a disabled or failed one is absent exactly as 4.4 already
leaves its routes and nav; no state, failure_stage or failure_reason reaches an
anonymous caller; no client chunk URL, because htmlShell hands the browser the
tag; an empty array is a real answer, but the 7.6 guard is a 500 and never [].
Also records why it owns the /modules prefix rather than sitting in the
root-mounted site router, where the loader's collision probe could not see it.
6.7 amends MODULE_SYSTEM 2.6 step 4, which said the SPA reads the endpoint "to
learn what to load" — step 3 of the same list had already answered loading a
different way, and 3.1.3 is the normative version. The endpoint feature-detects.
2.6 step 4 now says so, and Part 6's intro no longer claims a fixed count of
amendments.
MODULE_SYSTEM.md 2.7 records PR 6 and restates the phase exit criterion
honestly: no EXISTING URL moves, and PR 6 is the single deliberate addition in
Phase 2. Status line updated — the design is in implementation, not unimplemented.
BACKEND_DESIGN.md documents the route in the /public table and the router in the
folder map, and the published api-route-inventory.json mirror is refreshed to
228 public routes (the prose count was stale at 226).
Co-Authored-By: Claude <noreply@anthropic.com>
Records phase 2 PR 5 of the module system: the lifecycle hooks a module
registers, how they are dispatched, and what a boot does to installed_modules.
MODULE_API.md 2.5 gains the reconcile's four steps in order, the rules that fall
out of them (the operator's `disabled` wins over any outcome; a bookkeeping
failure is not a boot failure; a module with no onBoot still reaches `started`;
a module whose onBoot threw gets no onShutdown), and why onBoot has no timeout
while onShutdown has a five-second budget -- shutdown races the process being
killed and boot does not.
4.4 gains the failure_stage table: every failure is recorded against the 4.3
step that produced it, so the admin panel can say where a module broke and not
only what the message was.
MODULE_SYSTEM.md 2.4 records the new rule for a row whose directory is gone, and
2.7 the PR 5 progress entry with its four decisions. BACKEND_DESIGN.md's
installed_modules section gains the write path now that one exists.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 PR 4 of MODULE_SYSTEM.md §2.7. Records what §1.8's three entangled files
and §1.9's extension slot actually became, and four decisions taken with them.
MODULE_API.md §2.4:
- registerNotificationStreams takes the catalog ALONE. `mapEvent` was a leftover
from before §1.8's push inversion was settled — a module owns fromShardEvent
and calls publish() with an id it resolved, so core never needs a second route
to the same place. It follows that the public-safety filter is module-internal,
which is the right home: the kinds, the streams and the filter become one file
that moves together.
- the entry shape is two booleans, not a single `scope` — that object is the body
of GET /auth/me/notifications/streams and a shipped Android client reads both.
- registerAnnounceLeg gains `label`, so a module's leg renders in the admin panel
with no client change; and legs became `announce_job_legs` ROWS, because a
module cannot ALTER a core table and a registered leg had nowhere to live.
- every call STAGES; nothing commits until the module as a whole is known good.
New §6.5 — the eight grandfathered names (seven stream ids, one leg id), allowed
to `uo` alone by explicit allowlist, the same shape as the loader's legacy table
prefixes. Grandfathering rather than dropping the rule is what keeps it real for
every module written after this one.
New §6.6 — an extension slot is invisible to static analysis, so core needs §6.1's
fragment merge for its OWN slot fills, a phase earlier than the plan expected.
Moving the six users-detail routes behind the slot deleted 407 lines from
swagger-output.json while printing `Success`. Which slots and where each hangs are
both derived — from the registry and from the live express stack — because a
written-down copy drifts.
MODULE_SYSTEM.md §1.8 / §1.9 record the outcome per file, and §2.7 the progress.
BACKEND_DESIGN.md and website-README.md follow the moved files; the PUBLIC_KINDS
reference at §"visibility" named the wrong file and is corrected to
utils/shardBroadcast.js.
Co-Authored-By: Claude <noreply@anthropic.com>
Docs half of website PR 3 (phase 2). MODULE_SYSTEM.md 2.7 gains the PR 3 entry;
MODULE_API.md 2.6 gains the decisions the section did not previously answer.
The one that shapes the code: a fragment is VALIDATED at load time and EXECUTED
later, split on whether a database is needed to know the answer. Everything 2.6
states about the SQL is knowable by reading the file, so breaking one of those
rules costs a module its mount entirely rather than mounting and 503ing with its
tables half created. What is left for the replay is the class of failure only
the server can report, and that is post-mount and answers 503.
Also recorded: the rules are enforced as a leading-verb allowlist (CREATE,
ALTER, INSERT, UPDATE -- the four core's own schema.sql uses) rather than the
DROP denylist 2.6 words them as, because the file is replayed on every boot and
a denylist only ever bans what somebody thought of; the replay sits outside
ensureSchema's wait-for-the-database retry loop, so one module's bad SQL cannot
cost the boot two minutes; partial application is accepted, since MariaDB
self-commits DDL and no transaction could undo it; and `npm run seed` is the one
sanctioned caller that replays nothing, because it never requires app.js and so
has no scan to read.
Co-Authored-By: Claude <noreply@anthropic.com>
Records what phase 2 PR 2 decided against the two things MODULE_API.md
left open for it.
7.6 is settled as an explicit modules.load(tierRouters) call in app.js
rather than a require-time scan: the loader needs the tier routers handed
to it for the 4.3 check, which a require-time side effect cannot receive,
and a require's position enforces an ordering constraint invisibly.
4.1 and 4.3 gain the mechanics that fall out -- where the call must sit
in app.js and why in both directions, that mounting is a second pass
after validation, and that core's prefix ownership is probed on the live
tier routers with express's layer.match() rather than declared in a table
that was already stale in the spike.
2.7 records PR 2 as done.
Co-Authored-By: Claude <noreply@anthropic.com>
Part 7 of MODULE_API.md: the three exit criteria and their results, the two
contract changes the spike forced (ctx.express/ctx.validator and
window.__rg.jsxRuntime), the empirical confirmation of the OpenAPI split, the
loader's tested failure guarantees, and the three artifacts in the branch that
are consequences of stopping at six routes rather than intended shape.
Phase 1 is complete: docs/website/MODULE_API.md is written and the spike met
every exit criterion on website branch spike/module-atlas (cut from edge, never
merged).
Co-Authored-By: Claude <noreply@anthropic.com>
Both approved by the org lead 2026-08-10. §6.1 moves from open question to
decision A with the per-side obligations spelled out in a new §6.1a: modules
ship a swagger-fragment.json with fully-qualified paths and namespaced schema
keys, core merges started modules' fragments into /api/docs.json at request
time and always wins a key collision, and swagger-output.json stays exactly
what core's own routes generate.
Co-Authored-By: Claude <noreply@anthropic.com>
The normative contract between core and an installed module: the `ctx` handed
to a module's entry point, the `register*` calls, the client-side registry and
shared-dependency global, the schema-fragment rules, and the loader's
validation and failure obligations. Every member is derived from what the UO
code actually imports today, re-read against the working tree.
Part 6 records four places the survey contradicted MODULE_SYSTEM.md:
• OpenAPI generation is STATIC analysis (swagger-autogen parses app.js as
text), unlike routeManifest.js which walks the live Express stack. A
filesystem-scanning loader is invisible to it, so module routes would be
silently absent from swagger-output.json. Three options, one recommended;
needs a decision before Phase 2.
• The client contract is much larger than §2.1 implies — the atlas pages
import five core modules beyond React, so the plan needs a curated UI kit
and a request primitive on window.__rg.
• shardVisibility is module-owned and the atlas depends on it, so the spike
carries it; two copies coexist during the spike by design.
• Two counts corrected: 27 UO tables (not 25), 6 atlas routes (not 5).
MODULE_SYSTEM.md gains a pointer to the contract and the corrected table count.
Co-Authored-By: Claude <noreply@anthropic.com>