docs(website): settle the three de-entanglement registries and what they cost

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>
This commit is contained in:
2026-08-10 17:51:02 -05:00
parent bcd3a750e7
commit 58b435be70
4 changed files with 162 additions and 20 deletions

View File

@@ -157,12 +157,21 @@ validated at once rather than at first use.
```js
api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
api.registerExtension(slot, router)
api.registerNotificationStreams({ streams, mapEvent })
api.registerAnnounceLeg({ leg, dispatch, classify })
api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
**Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's
shape is checked at the call, so a malformed one throws with the registrant's own stack; whether the
name is *taken* can only be answered once the batch is complete, and is checked when the loader
commits it in its second pass. The consequence is the one that matters: a module that registers two
streams and then throws — or fails `checkDeclared` after `register()` returns — has left nothing
behind. A half-registered catalog would be worse than a missing one, because it is a subscribable
stream nothing will ever publish to. This is the registry-side twin of §4.3's second-pass mount rule,
and both exist for the same reason.
**`registerRoutes(mounts)`** — one `express.Router()` per prefix per tier:
```js
@@ -194,15 +203,42 @@ The router receives `req.params.id` from the parent (`mergeParams: true`). Two m
same slot is a collision and is rejected; core's own routes on the resource always win a path
conflict.
**`registerNotificationStreams({ streams, mapEvent })`** — §1.8's push catalog.
`streams` is an array of `{ id, label, description, scope }` appended to core's catalog (ids are
namespaced `<moduleId>.<name>` and rejected otherwise); `mapEvent(event) => streamId | null` is
called by core's dispatcher for events the module's own code publishes.
**`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.
**`registerAnnounceLeg({ leg, dispatch, classify })`** — §1.8's news dispatcher.
`leg` is a namespaced id, `dispatch(post) => Promise<void>` delivers, `classify(post) => boolean`
decides whether this leg wants the post. A leg that throws is retried by core's existing per-leg
retry and never blocks another leg.
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.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
@@ -665,6 +701,54 @@ files. Two consequences:
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.
---
## Part 7 — What the spike proved