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

Merged
whitlocktech merged 2 commits from feature/events-phase-9 into edge 2026-09-04 12:56:15 +00:00
Member

EVENTS_PLAN.md Phase 9. module-uo registers its first event actions — uo.broadcast,
uo.towncrier.post, uo.news.post — plus the uo.broadcasts budget dimension and the three
spawn-atlas option sources. MODULE_API is unchanged at 1.10.0: this registers against the
contract Phase 7 opened rather than widening it.

The write plane these use has existed since protocol 2.1 and the admin screens have driven it by
hand for months. What is new is the declaration that lets the event engine drive it unattended,
which is a different question — and most of the work here is about what happens when a call does
not come back.

  1. uo.towncrier.post ships alongside the towncrier announce leg, not instead of it. The leg
    is post-shaped, one-shot and non-reversible; the action is run-scoped, takes lines and a duration
    directly, and is reversible: 'ledger' so teardown takes it down. Each declaration says which it
    is, so an author picking in the step editor is not guessing.
  2. A uo.broadcasts budget dimension, on the broadcast alone. A run that broadcasts forty times
    is the spam failure mode and a per-run cap is the only thing between an authoring mistake and
    every player online. The keyed verbs get none: they post under a run-scoped id and a repeat
    REPLACES, so the runaway a cap would guard against does not exist for them.
  3. The option sources ship here, ahead of their Phase 12 consumers. They cost nothing, work with
    the shard down, and P12 is a five-repo protocol bump that should not also carry its first atlas
    plumbing.
  4. An event write records event:<runId> as its actor. No staff member pressed a button, and
    attributing it to one would be a false line in the game's own audit trail.

Three things the tree corrected about the plan

  • The plan's on_failure: 'skip' for uo.broadcast does not do what it says. skip is already
    the default for risk: 'notify', and on_failure is what happens AFTER the retries — there is no
    per-action lever meaning "do not retry me". The lever is the failure envelope, so the action
    answers retry: false to everything. And that alone was not enough: dispatch.classify()
    answers retry for a budget timeout unconditionally without asking the action, and the default
    budgetMs (10s) is shorter than uoLinkClient.TIMEOUT_MS (12s) — so core's deadline fired
    first on every slow shard and the refusal was unreachable code. All three actions declare
    budgetMs: 15000. This is the phase's most portable finding and it is now a contract rule in
    MODULE_API.md §2.4 rather than a fact about one module.
  • reconcile() needs no protocol work. There is no "list the crier lines" on the wire, and
    adding one would be protocol work for a question the module can already answer: a shard restart is
    definitionally the loss of both a crier line and an event's news article. 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. A row
    with no stamp is reported IN FORCE: "I do not know" must never read as "it is gone".
  • Event articles post under evt-<idempotencyKey>. newsGump.js posts site articles under the
    bare website post id and re-pushes that whole set on every reconnect; an event article numbered
    into the same space would be a silent collision, in whichever direction wrote last.

The live walk, and the three defects it found

A real two-phase event against ServUO + the sidecar + the website.

All three verbs reached the shard. The shard's own audit read
[Bridge][admin] web:event:3692 broadcast … — decision 4 visible where it was meant to be. The dry
run refused the event for asking 2 of uo.broadcasts against a cap of 1, before publishing, and
passed at 2. The option sources resolved from the real atlas — 387 regions, 558 landmarks, 800
creatures, all well inside the 2000 bound. Teardown reverted the crier line and the article. Then the
shard was restarted mid-run and core logged
event resources orphaned on reconcile {"asked":2,"inForce":0,"orphaned":2} 31ms after the bootId
changed
— the phase's headline property, proved rather than asserted.

None of the three was visible to a unit test.

  1. A WS reconnect would have orphaned every live resource. The backfill replays the last several
    server.hello frames in order — this rig saw three, each with a different bootId — so every
    replayed frame reads as a restart, and the intermediate ones compare a resource stamped with the
    CURRENT boot against a boot that ended hours ago. The row is then orphaned: a live crier line
    core will never take down again, lost to nothing worse than the website reconnecting. Gated on
    !fromBackfill, the rule the engagement fan-out and the SSE broadcast beside it already state.
    The website-was-down case is still covered by core's own boot sweep.
  2. The shard explains its refusals and the run log dropped the explanation. A 403 body reads
    {"reason":"admin write plane disabled"}; legError looks for data.message, finds nothing, and
    reports "sidecar responded 403". A staff member clicking a button knows what they switched off; an
    event that ran at four in the morning leaves the run log as the only place anyone will learn why.
  3. The "not retried" clause explained the wrong thing. A 403 will not succeed on any attempt, so
    "not retried: a repeat would announce twice" points an operator at a policy decision instead of at
    the switch they have to flip. The clause is now added only where a retry was genuinely given up,
    and 403/404 join the statuses the keyed verbs treat as terminal.

Two things for a reviewer to know, neither fixed here

  • ci/core-ref.json moves to a website edge sha for the length of this workstream (org lead,
    2026-09-04). registerEventActions exists only from MODULE_API 1.10.0, so under the previous
    main pin register() throws and the module does not load at all — the frozen-manifest job would
    have been red by construction for eight phases and proved nothing. Verified locally: the whole
    frozen-manifest rig (clone core at the pin, generate before/after, --check) passes against the
    new pin. Phase 16's cutover re-pins it to main.
  • A Phase 8 defect in core, left for its own change. UNRESOLVED in
    eventRunResources.db.js includes orphaned, so the cleanup sweep selects an orphaned row, calls
    revert() on it and records it reverted. That contradicts EVENTS.md §L's own rule — "a
    resource the module no longer has becomes orphaned, never reverted"
    — and the console then
    says core put back two things that had vanished. It also spends a sidecar round trip per orphaned
    row on a shard that has just restarted. Reproduced in the walk above; it is core's file, and a
    module-uo PR is the wrong place for it.

Verification

  • npm test --prefix server561 pass, 0 fail (34 new).

  • check:imports and check:bundle both green.

  • The frozen-manifest rig, run locally against the new pin — green.

  • The live walk above.

  • check:swagger reports STALE on a Windows working tree and it is noise: the generator writes
    LF, the tree is CRLF, and the check byte-compares — regenerating produces a zero-line content
    diff
    . Green on CI (Linux), and it is red on an untouched edge checkout here too, so it is not
    this branch. Left alone deliberately (org lead) rather than widening this PR.

  • AI-assisted: Claude Code (Opus 5).

Docs: RunicGateway/docs#217.

🤖 Generated with Claude Code

`EVENTS_PLAN.md` Phase 9. `module-uo` registers its first event actions — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post` — plus the `uo.broadcasts` budget dimension and the three spawn-atlas option sources. **`MODULE_API` is unchanged at 1.10.0**: this registers against the contract Phase 7 opened rather than widening it. The write plane these use has existed since protocol 2.1 and the admin screens have driven it by hand for months. What is new is the declaration that lets the event engine drive it *unattended*, which is a different question — and most of the work here is about what happens when a call does not come back. ## Four decisions (org lead, 2026-09-04), all as recommended 1. **`uo.towncrier.post` ships alongside the `towncrier` announce leg**, not instead of it. The leg is post-shaped, one-shot and non-reversible; the action is run-scoped, takes lines and a duration directly, and is `reversible: 'ledger'` so teardown takes it down. Each declaration says which it is, so an author picking in the step editor is not guessing. 2. **A `uo.broadcasts` budget dimension, on the broadcast alone.** A run that broadcasts forty times is the spam failure mode and a per-run cap is the only thing between an authoring mistake and every player online. The keyed verbs get none: they post under a run-scoped id and a repeat REPLACES, so the runaway a cap would guard against does not exist for them. 3. **The option sources ship here**, ahead of their Phase 12 consumers. They cost nothing, work with the shard down, and P12 is a five-repo protocol bump that should not also carry its first atlas plumbing. 4. **An event write records `event:<runId>` as its actor.** No staff member pressed a button, and attributing it to one would be a false line in the game's own audit trail. ## Three things the tree corrected about the plan - **The plan's `on_failure: 'skip'` for `uo.broadcast` does not do what it says.** `skip` is already the default for `risk: 'notify'`, and `on_failure` is what happens AFTER the retries — there is no per-action lever meaning "do not retry me". The lever is the failure envelope, so the action answers `retry: false` to everything. **And that alone was not enough:** `dispatch.classify()` answers `retry` for a budget timeout unconditionally without asking the action, and the default `budgetMs` (10s) is *shorter* than `uoLinkClient.TIMEOUT_MS` (12s) — so core's deadline fired first on every slow shard and the refusal was unreachable code. All three actions declare `budgetMs: 15000`. This is the phase's most portable finding and it is now a contract rule in `MODULE_API.md` §2.4 rather than a fact about one module. - **`reconcile()` needs no protocol work.** There is no "list the crier lines" on the wire, and adding one would be protocol work for a question the module can already answer: a shard restart is definitionally the loss of both a crier line and an event's news article. `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. A row with no stamp is reported IN FORCE: "I do not know" must never read as "it is gone". - **Event articles post under `evt-<idempotencyKey>`.** `newsGump.js` posts site articles under the bare website post id and re-pushes that whole set on every reconnect; an event article numbered into the same space would be a silent collision, in whichever direction wrote last. ## The live walk, and the three defects it found A real two-phase event against **ServUO + the sidecar + the website**. All three verbs reached the shard. The shard's own audit read `[Bridge][admin] web:event:3692 broadcast …` — decision 4 visible where it was meant to be. The dry run refused the event for asking 2 of `uo.broadcasts` against a cap of 1, *before* publishing, and passed at 2. The option sources resolved from the real atlas — 387 regions, 558 landmarks, 800 creatures, all well inside the 2000 bound. Teardown reverted the crier line and the article. Then the shard was restarted mid-run and core logged `event resources orphaned on reconcile {"asked":2,"inForce":0,"orphaned":2}` **31ms after the bootId changed** — the phase's headline property, proved rather than asserted. **None of the three was visible to a unit test.** 1. **A WS reconnect would have orphaned every live resource.** The backfill replays the last several `server.hello` frames in order — this rig saw three, each with a different `bootId` — so every replayed frame reads as a restart, and the intermediate ones compare a resource stamped with the CURRENT boot against a boot that ended hours ago. The row is then `orphaned`: a live crier line core will never take down again, lost to nothing worse than the website reconnecting. Gated on `!fromBackfill`, the rule the engagement fan-out and the SSE broadcast beside it already state. The website-was-down case is still covered by core's own boot sweep. 2. **The shard explains its refusals and the run log dropped the explanation.** A 403 body reads `{"reason":"admin write plane disabled"}`; `legError` looks for `data.message`, finds nothing, and reports "sidecar responded 403". A staff member clicking a button knows what they switched off; an event that ran at four in the morning leaves the run log as the only place anyone will learn why. 3. **The "not retried" clause explained the wrong thing.** A 403 will not succeed on any attempt, so "not retried: a repeat would announce twice" points an operator at a policy decision instead of at the switch they have to flip. The clause is now added only where a retry was genuinely given up, and 403/404 join the statuses the keyed verbs treat as terminal. ## Two things for a reviewer to know, neither fixed here - **`ci/core-ref.json` moves to a website `edge` sha** for the length of this workstream (org lead, 2026-09-04). `registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` throws and the module does not load at all — the frozen-manifest job would have been red by construction for eight phases and proved nothing. **Verified locally**: the whole frozen-manifest rig (clone core at the pin, generate before/after, `--check`) passes against the new pin. Phase 16's cutover re-pins it to `main`. - **A Phase 8 defect in core, left for its own change.** `UNRESOLVED` in `eventRunResources.db.js` includes `orphaned`, so the cleanup sweep selects an orphaned row, calls `revert()` on it and records it `reverted`. That contradicts `EVENTS.md` §L's own rule — *"a resource the module no longer has becomes `orphaned`, never `reverted`"* — and the console then says core put back two things that had vanished. It also spends a sidecar round trip per orphaned row on a shard that has just restarted. Reproduced in the walk above; it is core's file, and a `module-uo` PR is the wrong place for it. ## Verification - `npm test --prefix server` — **561 pass, 0 fail** (34 new). - `check:imports` and `check:bundle` both green. - The frozen-manifest rig, run locally against the new pin — green. - The live walk above. - **`check:swagger` reports STALE on a Windows working tree and it is noise**: the generator writes LF, the tree is CRLF, and the check byte-compares — regenerating produces a **zero-line content diff**. Green on CI (Linux), and it is red on an untouched `edge` checkout here too, so it is not this branch. Left alone deliberately (org lead) rather than widening this PR. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#217. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 2 commits 2026-09-04 12:38:29 +00:00
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>
fix(events): three defects the live rig found, two of them data loss
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m33s
021f191f65
The whole-rig walk (ServUO + sidecar + website) against a real two-phase event.

- **A WS reconnect would have orphaned every live resource.** The backfill
  replays the last several `server.hello` frames in order — this rig saw three,
  each with a different `bootId` — so every replayed frame reads as a restart,
  and the intermediate ones compare a resource stamped with the CURRENT boot
  against a boot that ended hours ago. The row is then `orphaned`: a live crier
  line core will never take down again, lost to nothing worse than the website
  reconnecting. Gated on `!fromBackfill`, the rule the engagement fan-out and
  the SSE broadcast beside it already state. The website-was-down case is not
  missed — core asks every module at its own boot.
- **The shard explains its refusals and the run log dropped the explanation.**
  A 403 body reads `{"reason":"admin write plane disabled"}`; `legError` looks
  for `data.message`, finds nothing, and reports "sidecar responded 403". For a
  staff member clicking a button that is survivable. For an event that ran at
  four in the morning the run log is the only place anyone will learn why.
- **The "not retried" clause explained the wrong thing on a permanent status.**
  A 403 will not succeed on any attempt, so telling an operator it was not
  retried "because a repeat would announce twice" points them at a policy
  decision instead of at the switch they have to flip. The clause is now added
  only where a retry was genuinely given up, and 403/404 join the statuses the
  keyed verbs treat as terminal.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit cf60932c85 into edge 2026-09-04 12:56:15 +00:00
whitlocktech deleted branch feature/events-phase-9 2026-09-04 12:56:16 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#28
No description provided.