feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)

module-uo registers its first event actions: `uo.broadcast`,
`uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget
dimension and the three spawn-atlas option sources. The write plane they use
has existed since protocol 2.1; what is new is the declaration that lets the
event engine drive it unattended.

Three things the tree corrected about the plan:

- The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for
  `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The
  lever a module actually has is the failure envelope, so the action answers
  `retry: false` to everything — and every action declares `budgetMs: 15000`,
  because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout
  and `classify()` answers `retry` for a timeout without asking the module.
  Without the budget the retry refusal is unreachable.
- `reconcile()` needs no protocol work. A shard restart wipes both the crier
  lines and an event's news article, so `perform()` stamps the shard `bootId`
  into the resource payload and `reconcile()` reports in force exactly the rows
  whose stamp still matches — correct for the module's own trigger and for
  core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a
  changed `bootId`, after `recordStatus` so the comparison reads the new boot.
- Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses
  the bare website post id and re-pushes that set on every reconnect.

`ci/core-ref.json` moves to a website `edge` sha for the length of this
workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under
the old `main` pin the module does not load at all. Verified locally — the
frozen-manifest rig passes against the new pin.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 07:23:03 -05:00
parent 144242fe8f
commit 57419111e6
11 changed files with 1136 additions and 11 deletions

View File

@@ -50,7 +50,7 @@ function fakeCtx(overrides = {}) {
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
// contract — a module gets no delivery answer back, deliberately — so the
// spies return undefined rather than a promise, which is what core does.
events: { emit: spy(undefined) },
events: { emit: spy(undefined), reconcile: spy(undefined) },
inbox: { push: spy(undefined) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
@@ -104,6 +104,9 @@ function fakeApi() {
slashCommands: [],
triggers: null,
audiences: null,
eventActions: null,
eventBudgets: null,
eventOptionSources: null,
hooks: {},
}
const called = new Set()
@@ -134,6 +137,12 @@ function fakeApi() {
// and merging two calls would make "which group is this rule in" — the
// question the one-shot seed guard answers — unanswerable.
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
// all three, matching core: it stages a registrant's whole batch and applies
// it as one, so a second call is a module changing its mind mid-register().
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}