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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user