diff --git a/website/EVENTS.md b/website/EVENTS.md index ce26c24..703ceef 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -848,6 +848,37 @@ api.registerEventLeases([{ in them, so `core.options.legs` costs nothing new, and it means the seam's first exercise is not a module's. +### What Phase 9 settled, as the contract's first module registrant + +Wave 1 — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post` — is the first time anything but core +registered an action, and three of the four things it found are rules rather than facts about UO. + +- **`budgetMs` must EXCEED the timeout of whatever the action talks to.** The dispatcher classifies a + budget timeout as `retry` unconditionally and does not ask the action — it cannot, the action is + still awaiting a socket. So an action whose own client gives up *after* core's deadline never gets + to classify its own failure, and `retry: false` in its envelope is unreachable. The default + `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, the wrong way round, and every slow + shard produced a retry the module had explicitly refused. Now a rule in `MODULE_API.md` §2.4. +- **There is no per-action "do not retry me", and `on_failure` is not one.** `on_failure` is what + happens AFTER `EVENT_STEP_MAX_ATTEMPTS`, so `skip` — already the `notify` default — does not stop a + broadcast going out three times. The lever is the failure envelope, and an action that must be + attempted once answers `retry: false` to everything. That is a heavier thing to ask of a + declaration than the plan assumed, and it is the reason the rule above is load-bearing rather than + a tuning note. +- **`reconcile()` need not be a query.** A module that knows *when* its resources are lost can answer + from a stamp instead of a round trip: wave 1 records the shard's `bootId` in the resource payload + and reports in force exactly the rows whose stamp still matches. That is correct for the module's + own trigger AND for core's boot sweep — where the game may not have restarted at all, and where + answering "all gone" would abandon live rows. A row with no stamp is reported IN FORCE, because + "I do not know" must never read as "it is gone", which is `reconcileModule`'s own posture stated + from the module's side. +- **A module's `reconcile()` trigger must fire on a LIVE signal, never a replay.** §L says the module + says when; it did not say that a module's event feed may replay history. `module-uo`'s sidecar + reconnect backfills the last several `server.hello` frames, each with a different `bootId`, so every + replayed frame reads as a restart — and an intermediate one compares a resource stamped with the + current boot against a boot that ended hours ago and orphans it. A live resource core will never + take back, lost to a reconnect. Any module wiring this seam owes the same gate. + ### What is contract rather than implementation **Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02). diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 016e0b8..9387115 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -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:` 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`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index f14a8c6..cbb05d0 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -114,7 +114,10 @@ api.registerEventLeases([{ field: - **No shape a failure can take reads as success.** A rejected promise, a throw, a `budgetMs` - timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. That is + timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. **A module that + needs the `retry: false` half of that to be reachable must declare a `budgetMs` longer than its + own transport's timeout** — see §2.4's rule, which exists because the first module to register an + action did not, and its one non-retryable verb was retried anyway. That is `registerTeamProvider`'s default *inverted*, deliberately: a team provider that refuses leaves core showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that @@ -1008,6 +1011,15 @@ rather than implementation and belong here: dispatcher: without it a `perform()` awaiting a socket that never answers holds a step's claim until its lease expires, and the reclaim then re-dispatches it, which is how one wedged sidecar becomes an infinite loop rather than a failed step. +- **`budgetMs` must EXCEED the timeout of whatever the action talks to** (Events Phase 9). The + dispatcher classifies a budget timeout as `retry` unconditionally and does not ask the action — + it cannot, the action is still awaiting a socket. So an action whose own client gives up *after* + core's deadline never gets to classify its own failure, and `retry: false` in its envelope is + unreachable code. The default `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, which + is the wrong way round: every slow shard produced a retry the module had explicitly refused. The + rule generalises past that one pairing — an action is the near end of a call with a far end, and + the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it + is attempted once, declares 15000. - **`once`, on all four.** A batch is a module's complete statement about what it declares; a second call is a module changing its mind halfway through `register()` rather than adding to it. And they STAGE, like every registration above: a module that registers two budgets and then throws has left