docs(events): Phase 9 as built — UO wave 1, and a budgetMs rule the phase found
`EVENTS_PLAN.md` Phase 9 as built, plus the two findings that are contract rather than facts about UO. - **MODULE_API.md §2.4: `budgetMs` must exceed the timeout of whatever the action talks to.** The dispatcher classifies a budget timeout as `retry` unconditionally and cannot ask the action, so an action whose own client gives up after core's deadline never classifies its own failure and `retry: false` is unreachable. The default (10s) is shorter than module-uo's sidecar client (12s), the wrong way round. - **EVENTS.md §F gains "What Phase 9 settled, as the contract's first module registrant"**: the rule above; that `on_failure` is not a retry policy; that `reconcile()` can answer from a stamp rather than a query; and that a module's reconcile trigger must fire on a live signal, never a replay. The plan block records the four org-lead decisions, the live walk, the three defects it found — two of them data loss — and the Phase 8 defect in core it reproduced and deliberately did not fix here. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1028,6 +1028,76 @@ not** — a retry is a second announcement to everyone online — and there is n
|
||||
wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
|
||||
declaration says why.
|
||||
|
||||
> **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option
|
||||
> sources, `MODULE_API` unchanged at 1.10.0 — the module registers against the contract Phase 7
|
||||
> opened rather than widening it.
|
||||
>
|
||||
> **The trap above is real and its stated fix does not work.** `on_failure: 'skip'` is already the
|
||||
> default for `risk: 'notify'`, and `on_failure` is what happens AFTER `EVENT_STEP_MAX_ATTEMPTS`
|
||||
> retries — there is no per-action lever meaning "do not retry me". The lever a module has is the
|
||||
> failure envelope, so `uo.broadcast` answers `retry: false` to everything. **And that alone is not
|
||||
> enough**: `dispatch.classify()` answers `retry` for a budget timeout unconditionally without asking
|
||||
> the action, and the default `budgetMs` (10s) is SHORTER than `uoLinkClient`'s own timeout (12s), so
|
||||
> core's deadline fired first on every slow shard and the refusal was unreachable. All three actions
|
||||
> declare `budgetMs: 15000`. That 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()` needed no protocol work.** There is no "list the crier lines" or "list the news
|
||||
> articles" on the wire, and adding one would be protocol work for a question the module can already
|
||||
> answer: both live in shard memory, so a restart is definitionally the loss of both. `perform()`
|
||||
> stamps the shard's `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, where the shard may not have restarted at all and answering "all gone" would abandon live
|
||||
> rows. A row with no stamp is reported IN FORCE: "I do not know" must never read as "it is gone".
|
||||
>
|
||||
> **Four decisions (org lead, 2026-09-04), all as recommended.** `uo.towncrier.post` ships ALONGSIDE
|
||||
> the existing `towncrier` announce leg rather than replacing 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'`; a budget dimension `uo.broadcasts` bounds the broadcast alone, because the
|
||||
> keyed verbs replace under their own id and have no runaway to bound; the option sources ship here
|
||||
> rather than with their Phase 12 consumers; and an event write records `event:<runId>` as its actor,
|
||||
> because no staff member pressed a button and attributing it to one would be a false line in the
|
||||
> game's own audit trail.
|
||||
>
|
||||
> **The walk: 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`, which is
|
||||
> the actor decision 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 news article. Then the shard was restarted mid-run
|
||||
> and core logged `orphaned on reconcile {asked: 2, inForce: 0, orphaned: 2}` **31 milliseconds after
|
||||
> the bootId changed** — the phase's headline property, proved rather than asserted.
|
||||
>
|
||||
> **Three defects it found, two of them data loss, none visible to any 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`, which is 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`, and the console
|
||||
> said "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.
|
||||
>
|
||||
> **And one it found in Phase 8's shipped code, 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 §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. Not fixed here: it is core's file and a Phase 9 PR is the wrong place for it.
|
||||
>
|
||||
> **`ci/core-ref.json` moves to a website `edge` sha** for the length of this workstream (org lead).
|
||||
> `registerEventActions` exists only from 1.10.0, so under the old `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. Phase 16's cutover re-pins it to `main`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Integrations (`website` + `docs`)
|
||||
|
||||
Reference in New Issue
Block a user