feat(modules): the three de-entanglement registries, with core as the registrant #131

Merged
whitlocktech merged 2 commits from feature/module-registries into edge 2026-08-10 23:18:59 +00:00
Member

Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it — so the three seams §1.8 and §1.9 named are exercised on every boot, long before a module depends on them. Docs half: docs#129.

Nothing an operator or a client can see changes. 884 tests pass (856 before), routes.manifest.json is unchanged at 229 routes, and the OpenAPI diff is two lines of intent.

The four decisions

Announce legs became a child table announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at) replaces the towncrier_* / discord_* column groups. A module cannot ALTER a core table, so a registered leg had nowhere to live — the registry's signature was a promise the schema could not keep. Doing it now rather than in Phase 3 means doing it without also moving forty files.
mapEvent dropped from registerNotificationStreams §1.8 already inverts the push path: a module owns fromShardEvent and calls core's publish() with a stream id it resolved. A second mapping mechanism was a leftover. The public-safety filter, the kinds it reads and the streams it protects now live in one file and move together.
Core registers through the same staging area a module uses via an explicit registries.registerCore() in app.js before modules.load(). A registry only core's hardcoded base bypasses is one whose first real exercise is a module.
The six /admin/users/:id/shard/* paths moved behind the slot now Core fills its own admin.users.detail slot; getUser moved back to admin.controller.js per §1.9. Phase 3 changes the registrant, not the routes.

Validate-then-commit

The loader stages what a module claims and the second pass commits it. A module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. A half-registered stream catalog would be worse than a missing one: it is a subscribable stream nothing will ever publish to. This is the registry-side twin of PR 2's second-pass mount rule, and two new loader tests pin it.

Two build tools needed teaching

Both because a mechanism this PR introduces is one they had never seen.

  • scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected (?:([^\/]+?)); express 4.22 emits (?:\/([^/]+?)), with the separator inside the group. The branch had never run, because until now every mount in the tree was literal. It threw rather than guessing, which is exactly what it is for.

  • swagger-autogen could not follow a route into an extension slot. The slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed Swagger-autogen: Success — the spike's exact failure (MODULE_API.md §7.4), and it would have shipped six undocumented core routes.

    swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree about what a mount decodes to. Which slots and where each hangs are both derived, never written down. An empty fragment is a hard build failure, because an empty fragment is what the silent drop looks like. swagger/mergeSpec.js is the ~40-line merge helper §6.1a already owed core for module fragments — written here and proved against core's own slot first.

Verification

  • 884 tests pass (856 before): 15 new in moduleRegistries.test.js, 9 in announceLegs.test.js, 3 in adminUsers.test.js, 2 more loader tests, plus retargets.
  • routes.manifest.json: zero-line diff, 229 routes. routes.guards.json moves by one line — the DELETE unlink route's handler count 5→4, from dropping an inline adminOnly the router-level use(adminOnly) already applies. Its gate list is unchanged, as are all six slot routes'.
  • OpenAPI: 2 lines, both intended — the retry endpoint's summary, and its leg no longer being a fixed ["towncrier","discord"] enum.
  • The migration ran against the live dev database, not just in theory: three legacy jobs' leg data carried over faithfully, then two further replays with no error and no duplicate rows. The backfill is guarded on information_schema because a SELECT of a dropped column is a parse error, not a runtime one; the columns go with DROP COLUMN IF EXISTS, MariaDB-native and replay-safe. npm run seed still works (PR 3's second-entry-point trap).
  • Client builds; client suite 111/111.

What is left of towncrier in core

The structural mentions are gone — no columns, no LEGS array, no branch in the worker. What remains is prose, the LEGACY_LEGS allowlist, the two registerCore() lines Phase 3 turns into module-uo's register(), and the one-time backfill block in schema.sql, which is documented as deletable once every deployment has booted it. Phase 3's acceptance grep should expect those.

AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8

Phase 2 PR 4 of `docs/website/MODULE_SYSTEM.md` §2.7. Adds `server/src/modules/registries.js` and moves core's own notification streams, announce leg and users-detail routes behind it — so the three seams §1.8 and §1.9 named are exercised on every boot, long before a module depends on them. Docs half: **docs#129**. **Nothing an operator or a client can see changes.** 884 tests pass (856 before), `routes.manifest.json` is unchanged at 229 routes, and the OpenAPI diff is two lines of intent. ## The four decisions | | | |---|---| | **Announce legs became a child table** | `announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)` replaces the `towncrier_*` / `discord_*` column groups. A module cannot `ALTER` a core table, so a registered leg had nowhere to live — the registry's signature was a promise the schema could not keep. Doing it now rather than in Phase 3 means doing it *without* also moving forty files. | | **`mapEvent` dropped** from `registerNotificationStreams` | §1.8 already inverts the push path: a module owns `fromShardEvent` and calls core's `publish()` with a stream id it resolved. A second mapping mechanism was a leftover. The public-safety filter, the kinds it reads and the streams it protects now live in one file and move together. | | **Core registers through the same staging area a module uses** | via an explicit `registries.registerCore()` in `app.js` before `modules.load()`. A registry only core's hardcoded base bypasses is one whose first real exercise is a module. | | **The six `/admin/users/:id/shard/*` paths moved behind the slot now** | Core fills its own `admin.users.detail` slot; `getUser` moved back to `admin.controller.js` per §1.9. Phase 3 changes the registrant, not the routes. | ## Validate-then-commit The loader **stages** what a module claims and the second pass **commits** it. A module that throws halfway through `register()` — or fails `checkDeclared` after it — leaves nothing behind. A half-registered stream catalog would be worse than a missing one: it is a subscribable stream nothing will ever publish to. This is the registry-side twin of PR 2's second-pass mount rule, and two new loader tests pin it. ## Two build tools needed teaching Both because a mechanism this PR introduces is one they had never seen. - **`scripts/routeManifest.js` could not decode a parameterised mount.** Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))`, with the separator inside the group. The branch had never run, because until now every mount in the tree was literal. It **threw rather than guessing**, which is exactly what it is for. - **`swagger-autogen` could not follow a route into an extension slot.** The slot's router is created by `declareSlot()` and filled later, so there is no literal mount for a static parse. Regenerating deleted **407 lines** and printed `Swagger-autogen: Success` — the spike's exact failure (`MODULE_API.md` §7.4), and it would have shipped six undocumented core routes. `swagger/slotSpecs.js` generates a fragment per filled slot and re-roots it at the prefix the router *actually* hangs at in the live app — read from the express stack via `routeManifest`'s own `mountPath`, so the manifest and the spec cannot disagree about what a mount decodes to. Which slots and where each hangs are both derived, never written down. An empty fragment is a hard build failure, because an empty fragment is what the silent drop looks like. `swagger/mergeSpec.js` is the ~40-line merge helper §6.1a already owed core for module fragments — written here and proved against core's own slot first. ## Verification - **884 tests pass** (856 before): 15 new in `moduleRegistries.test.js`, 9 in `announceLegs.test.js`, 3 in `adminUsers.test.js`, 2 more loader tests, plus retargets. - **`routes.manifest.json`: zero-line diff**, 229 routes. `routes.guards.json` moves by one line — the DELETE unlink route's handler count 5→4, from dropping an inline `adminOnly` the router-level `use(adminOnly)` already applies. Its gate list is unchanged, as are all six slot routes'. - **OpenAPI: 2 lines**, both intended — the retry endpoint's summary, and its `leg` no longer being a fixed `["towncrier","discord"]` enum. - **The migration ran against the live dev database**, not just in theory: three legacy jobs' leg data carried over faithfully, then two further replays with no error and no duplicate rows. The backfill is guarded on `information_schema` because a `SELECT` of a dropped column is a *parse* error, not a runtime one; the columns go with `DROP COLUMN IF EXISTS`, MariaDB-native and replay-safe. `npm run seed` still works (PR 3's second-entry-point trap). - Client builds; client suite 111/111. ## What is left of `towncrier` in core The structural mentions are gone — no columns, no `LEGS` array, no branch in the worker. What remains is prose, the `LEGACY_LEGS` allowlist, the two `registerCore()` lines Phase 3 turns into module-uo's `register()`, and the one-time backfill block in `schema.sql`, which is documented as deletable once every deployment has booted it. Phase 3's acceptance grep should expect those. AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8
wtclaude added 1 commit 2026-08-10 22:52:07 +00:00
feat(modules): the three de-entanglement registries, with core as the registrant
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m39s
PR Checks / bot-install (pull_request) Successful in 8m49s
6195c76d61
Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js
and moves core's own notification streams, announce leg and users-detail routes
behind it, so the three seams §1.8 and §1.9 named are exercised on every boot
before any module depends on them.

Registering is validate-then-commit per registrant: the loader stages what a
module claims and the second pass commits it, so a module that throws halfway
through register() — or fails checkDeclared after it — leaves nothing behind.
That is the registry-side twin of PR 2's second-pass mount rule.

Four decisions, all the recommended option:

- announce legs became a child table. `announce_job_legs` replaces the
  towncrier_*/discord_* column groups, so the leg set is data: core registers
  `discord`, module-uo will register `towncrier`, and a module cannot ALTER a
  core table to add its own. Backfill is guarded on information_schema (a
  SELECT of a dropped column is a parse error, not a runtime one) and the
  columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB:
  three legacy jobs migrated faithfully, three replays, no duplicates.
- `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the
  push path so a module owns fromShardEvent and calls core's publish() with a
  stream id it resolved; a second mapping mechanism was a leftover. The public
  safety filter, the kinds it reads and the streams it protects now live in one
  file and move together.
- core registers through the same staging area a module uses, via an explicit
  registries.registerCore() in app.js before modules.load().
- core's six /admin/users/:id/shard/* paths now go through the
  `admin.users.detail` slot, and getUser moved back to admin.controller.js.

Found on the way, and the reason two build tools changed:

- scripts/routeManifest.js could not decode a parameterised mount. Its
  unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with
  the separator inside the group. The branch had never run. It threw rather
  than guessing, which is what it is for.
- swagger-autogen cannot follow a route into an extension slot — the slot's
  router is created by declareSlot() and filled later, so there is no literal
  mount for a static parse. Regenerating deleted 407 lines and printed
  `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4).
  swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at
  the prefix the router actually hangs at in the live app — read from the
  express stack via routeManifest's own mountPath, so the manifest and the spec
  cannot disagree. swagger/mergeSpec.js is the merge helper core owes for
  module fragments anyway (§6.1a), proved here against core's own slot first.

884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes.
The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and
its `leg` no longer being a fixed enum.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-08-10 22:54:05 +00:00
docs(modules): note that registerExtension's spec-file argument is core-only
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m40s
97f19b4221
Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 39eaae90a8 into edge 2026-08-10 23:18:59 +00:00
whitlocktech deleted branch feature/module-registries 2026-08-10 23:19:00 +00:00
Sign in to join this conversation.
No description provided.