docs(website): settle boot/shutdown dispatch and what a boot writes down

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>
This commit is contained in:
2026-08-10 20:32:10 -05:00
parent 30589f1fa5
commit 004e217806
3 changed files with 109 additions and 7 deletions

View File

@@ -258,13 +258,59 @@ It runs **after** `ensureSchema()` (so the module's own tables exist) and after
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 the server closes, in reverse registration order, with a 5-second budget
per module; exceeding it is logged and skipped rather than hanging the process.
`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.
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.
**`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
@@ -584,6 +630,26 @@ Two sub-cases differ, and the difference matters:
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