feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 39s

The website's half of protocol 6.

Every event-driven write now carries the step's idempotency key, and `uo.broadcast`
stops being un-retryable. Phase 9 shipped it answering `retry: false` to everything
including a 503 from a shard that was merely restarting, with a comment naming the
line that would change when the wire could refuse a repeat. This is that line: it
defers to `sidecarFailure`, the same helper its two siblings already used, so the
hand-rolled variant that forced every outcome terminal is gone rather than re-tuned.

One verb was less idempotent than its own id made it look. Both keyed verbs post
under a run-scoped id and a repeat replaces — but `news.add` with `announce: true`
makes the criers proclaim the title on every post, so a retry replaced the article
silently and proclaimed it again. The key stops the second proclamation.

`champ.boss.killed` is mapped to the `champs` feature (rule 2 would otherwise fail
it closed to admin), with `damagers` a nested `staff` field rule: the kill is public
because a champion falling is what the board is for, the ranked roll of who was
strong enough to fell it is not. `uo.champ.boss_killed` is declared as a trigger —
which is what makes it usable as an event PHASE CONDITION, since a condition is
written over a trigger firing — and it carries `damagerCount`, never a damager name,
because a trigger variable reaches mail an operator may address to every subscriber.

Its seeded rule is its own group, `champ-boss-killed-v1`: `triggers-v1` is stamped
once under a settings guard, so appending a 27th entry would have reached fresh
installs and nothing else. It also ships email+inapp and NOT push, and the comment
says why — no trigger in this module is also a registered stream, so no engagement
rule here can push. That is pre-existing in twenty rules and flagged rather than
fixed; this one declines to be the twenty-first.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:57:26 -05:00
parent cf60932c85
commit dc13515927
10 changed files with 501 additions and 64 deletions

View File

@@ -24,14 +24,31 @@
//
// That is not a tuning detail. It is the whole of what makes rule 2 true.
//
// **2. A broadcast is attempted exactly once.** `on_failure` is what happens
// AFTER `EVENT_STEP_MAX_ATTEMPTS` retries, and `skip` — the default for `notify`
// — is a disposition, not a retry policy; there is no per-action lever that says
// "do not retry me". The lever a module HAS is the failure envelope, so
// `uo.broadcast` answers `retry: false` to everything. A retried broadcast is a
// second announcement to everyone online, and there is no idempotency key on the
// wire until Phase 11 to make the shard refuse the repeat. A lost announcement is
// cheaper than a doubled one.
// **2. A broadcast is retried, and protocol 6 is what changed that.** Wave 1
// shipped `uo.broadcast` answering `retry: false` to everything, because a
// retried broadcast was a second announcement to everyone online and nothing on
// the wire could make the shard refuse the repeat. A lost announcement was
// cheaper than a doubled one, and that was the whole argument.
//
// Protocol 6 removes its premise. Every write below now carries the step's
// `idempotencyKey`; the shard executes a key at most once and answers a repeat
// with the ORIGINAL reply rather than re-running it. So a retry of a broadcast
// whose acknowledgement was lost cannot announce twice — it collects the answer
// the first attempt never delivered. A shard restarting mid-run is now recovered
// from rather than written off, which is the case rule 2 used to throw away
// knowingly.
//
// Rule 1 is what keeps this true rather than merely intended: if core's deadline
// fired first the module would never be asked, and the retry would be core's
// unconditional one — carrying the same key, so still safe, but classified
// without the module's judgement.
// **2a. The one status that is new here.** A repeat arriving while the original
// is still in flight on the shard is answered `bridge.busy`, which the sidecar
// maps to **425**. It is transient by construction: the work is happening. It is
// not in `PERMANENT_STATUSES` and `classify()` falls through to retry, so it
// needs no arm of its own — but it is named so that a future tightening of that
// list has to decide about it deliberately.
//
// **3. What a shard restart wipes, `reconcile()` reports gone — and it knows
// which restart it was without asking.** There is no "list the town-crier lines"
@@ -227,7 +244,7 @@ const ACTIONS = [
},
],
async perform({ runId, params, verify }) {
async perform({ runId, idempotencyKey, params, verify }) {
const text = String(params.text == null ? '' : params.text).trim()
// Checked here rather than left to the sidecar's 400, so the DRY RUN shows
// the author the refusal — which is the whole point of having one.
@@ -250,29 +267,25 @@ const ACTIONS = [
actor: `event:${runId}`,
text,
hue: params.hue === undefined || params.hue === null ? undefined : Number(params.hue),
// Protocol 6, and the line rule 2 said would change. The key is the
// step's, so every attempt at this step carries the same one and the
// shard refuses the repeat — which is what makes the retry below safe to
// ask for at all.
idempotencyKey,
})
if (result.ok) return { ok: true }
// **Every failure is terminal, deliberately** — see rule 2 in the header.
// A 503 from a shard that is merely restarting IS transient and this throws
// that retry away; that is the trade, taken knowingly, because the failure
// this refuses to risk is announcing twice to everyone online. Phase 11
// puts an idempotency key on the wire and this line is what changes.
// **A transient failure is now retried**, where wave 1 gave up on it. What
// used to make a retry unsafe was that the shard could not tell a repeat
// from a fresh command; it can now, so a 503 from a shard that is merely
// restarting is recovered from instead of being written off.
//
// **The clause is only added where a retry was genuinely given up**, and
// the rig is what made that distinction matter. A 403 — the shard's admin
// write plane switched off — 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 when what they need is the sentence the shard
// already wrote: "admin write plane disabled". A reason that explains the
// wrong thing is worse than a bare status code.
const reason = sidecarReason(result, 'broadcast')
if (PERMANENT_STATUSES.has(result.status)) return { ok: false, retry: false, error: reason }
return {
ok: false,
retry: false,
error: `${reason} (not retried: a repeat would announce twice)`,
}
// The classification itself is `sidecarFailure`'s — the announce leg's own
// judgement about this transport, deferred to rather than second-guessed,
// exactly as the two keyed verbs below already do. That this action now
// uses the SAME helper as its siblings, instead of a hand-rolled variant
// that forced every outcome terminal, is most of the change here.
return sidecarFailure(result, 'broadcast')
},
},
@@ -329,7 +342,18 @@ const ACTIONS = [
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
const result = await uoLinkClient.postTownCrier({ id, lines: parsed.lines, durationSec })
// Protocol 6. This verb was already safe to retry — a repeat under the same
// `id` REPLACES the crier entry rather than stacking a second one — so the
// key buys no new safety here. It is sent because it costs nothing and
// makes the retry a no-op on the shard rather than a redundant world write,
// and because a write plane where only some commands are keyed is one
// somebody will later have to reason about per verb.
const result = await uoLinkClient.postTownCrier({
id,
lines: parsed.lines,
durationSec,
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'town-crier post')
// The stamp rule 3 rests on. `runId` rides along so a row read out of the
@@ -435,6 +459,13 @@ const ACTIONS = [
params.image === undefined || params.image === null ? undefined : Number(params.image),
url: params.url || undefined,
announce: params.announce === undefined || params.announce === null ? true : Boolean(params.announce),
// Protocol 6, for the same reason the crier carries one — except that
// here it does buy something. `announce: true` makes the criers proclaim
// the article's title when it is posted, so a re-post under the same id
// replaces the article silently but proclaims it AGAIN. The key stops the
// second proclamation, which was the one part of this verb that was never
// as idempotent as its `id` made it look.
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'news article')