docs(website): settle the module loader's trigger and its collision check

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>
This commit is contained in:
2026-08-10 14:36:22 -05:00
parent b5277d827c
commit 9355f2aec4
2 changed files with 53 additions and 8 deletions

View File

@@ -454,6 +454,24 @@ half needs rethinking before Phase 2 builds on 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 volume mount.
**The trigger is one explicit call, and there is no lazy self-scan** (§7.6). `app.js` calls
```js
modules.load({ public: publicRouter, admin: adminRouter, player: playerRouter })
```
exactly once, and every accessor throws until it has run rather than answering with an empty list —
"no modules installed" is a real state, and a caller must not be handed it by accident. Its position
in `app.js` is load-bearing in both directions: **after** `app.use('/api', apiRouter)`, so every core
prefix is already on the tier routers when §4.3 asks them what core owns and so first-match-wins
means a module cannot shadow a core route; **before** the `/api` 404, so a module route reaches its
handler instead of the catch-all.
**Mounting is a second pass** over the modules that survived validation, not part of the scan loop.
Otherwise the first module's layers sit on the tier router while the second is being validated,
indistinguishable from core's — the second module would be told it collided with *core*, naming the
wrong culprit, and the module-versus-module check would be unreachable.
### 4.2 Order
Alphabetical by `id`, deterministically. There is no dependency resolution between modules (§2.0 of
@@ -472,6 +490,14 @@ other order would imply a precedence that is not being computed.
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,
@@ -690,10 +716,15 @@ They are recorded so nobody reads them as intended shape:
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
### 7.6 A finding for Phase 2's loader — **settled: an explicit `load()`**
`scan()` is lazy — requiring the loader does not run it. That is deliberate (app.js decides when
`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
should either make the trigger explicit in the name or scan at require time and let app.js order the
require.
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.