feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9) #28
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/events-phase-9"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
EVENTS_PLAN.mdPhase 9.module-uoregisters its first event actions —uo.broadcast,uo.towncrier.post,uo.news.post— plus theuo.broadcastsbudget dimension and the threespawn-atlas option sources.
MODULE_APIis unchanged at 1.10.0: this registers against thecontract 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
uo.towncrier.postships alongside thetowncrierannounce leg, not instead of it. The legis 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 itis, so an author picking in the step editor is not guessing.
uo.broadcastsbudget dimension, on the broadcast alone. A run that broadcasts forty timesis 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.
the shard down, and P12 is a five-repo protocol bump that should not also carry its first atlas
plumbing.
event:<runId>as its actor. No staff member pressed a button, andattributing it to one would be a false line in the game's own audit trail.
Three things the tree corrected about the plan
on_failure: 'skip'foruo.broadcastdoes not do what it says.skipis alreadythe default for
risk: 'notify', andon_failureis what happens AFTER the retries — there is noper-action lever meaning "do not retry me". The lever is the failure envelope, so the action
answers
retry: falseto everything. And that alone was not enough:dispatch.classify()answers
retryfor a budget timeout unconditionally without asking the action, and the defaultbudgetMs(10s) is shorter thanuoLinkClient.TIMEOUT_MS(12s) — so core's deadline firedfirst 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 inMODULE_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, andadding 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 theshard
bootIdinto the resource payload andreconcile()reports in force exactly the rows whosestamp 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".
evt-<idempotencyKey>.newsGump.jsposts site articles under thebare 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 dryrun refused the event for asking 2 of
uo.broadcastsagainst a cap of 1, before publishing, andpassed 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 bootIdchanged — the phase's headline property, proved rather than asserted.
None of the three was visible to a unit test.
server.helloframes in order — this rig saw three, each with a differentbootId— so everyreplayed 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 linecore 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.
{"reason":"admin write plane disabled"};legErrorlooks fordata.message, finds nothing, andreports "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.
"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.jsonmoves to a websiteedgesha for the length of this workstream (org lead,2026-09-04).
registerEventActionsexists only from MODULE_API 1.10.0, so under the previousmainpinregister()throws and the module does not load at all — the frozen-manifest job wouldhave 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 thenew pin. Phase 16's cutover re-pins it to
main.UNRESOLVEDineventRunResources.db.jsincludesorphaned, so the cleanup sweep selects an orphaned row, callsrevert()on it and records itreverted. That contradictsEVENTS.md§L's own rule — "aresource the module no longer has becomes
orphaned, neverreverted" — and the console thensays 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-uoPR is the wrong place for it.Verification
npm test --prefix server— 561 pass, 0 fail (34 new).check:importsandcheck:bundleboth green.The frozen-manifest rig, run locally against the new pin — green.
The live walk above.
check:swaggerreports STALE on a Windows working tree and it is noise: the generator writesLF, 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
edgecheckout here too, so it is notthis 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
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>