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

@@ -467,6 +467,38 @@ const TEMPLATES = [
'{{champsUrl}}',
),
// ── The champion falls (Protocol 6) ─────────────────────────────────────
//
// The other half of the pair above, and the half the wire could not report
// until protocol 6 gave the shard a kind for it. Written as the crier's own
// follow-up: the same voice that announced the champion walking is the one
// that reports it did not walk far.
//
// `{{damagerNote}}` is a single-token block, so an unattributed kill renders
// the paragraph without it rather than as a sentence with a hole in it.
email(
'uo.champ.boss-killed',
'Champion spawn — the champion falls (town crier)',
'uo.champ.boss_killed',
'Hear ye — {{bossName}} has fallen',
[
heading('h', 'Hear ye, hear ye'),
text('p1',
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}} The altar is quiet again, and it '
+ 'will not stay quiet.'),
button('cta', 'See the altars', '{{champsUrl}}'),
],
),
inapp(
'uo.champ.boss-killed-inapp',
'Champion spawn — the champion falls (in-app)',
'uo.champ.boss_killed',
'{{bossName}} has fallen',
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}}',
'See the altars',
'{{champsUrl}}',
),
// ── A guildmaster of the craft ──────────────────────────────────────────
email(
'uo.skill.capped',
@@ -624,6 +656,14 @@ const TEMPLATES = [
const CHANNELS_OWNER = ['email', 'inapp']
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
// The same two channels as CHANNELS_OWNER and a different reason for them: a
// rule that goes to every subscriber but cannot be PUSHED, because push is
// keyed on a subscription id and no trigger in this module is also a registered
// stream. Same value, different fact — folding them into one constant would lose
// the distinction the moment somebody added push to whichever one they read as
// "the broadcast-ish list". See `uo.champ.boss_killed`.
const CHANNELS_CONTENT = ['email', 'inapp']
/** In-universe: both bodies are this module's, the digest is core's. */
const bodies = (key) => ({
email: `uo.${key}`,
@@ -863,6 +903,32 @@ const RULES = [
cooldown_seconds: 1800,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.champ.boss_killed',
name: 'Champion spawn — the champion falls',
audience: 'subscribers',
// **`CHANNELS_CONTENT`, not `CHANNELS_BROADCAST`** — this is the one rule in
// the file that leaves push out, and it is not an oversight.
//
// Push delivery is keyed on the SUBSCRIPTION id, and a subscription row only
// ever exists for an id the preferences screen offered a push toggle for —
// which core's catalog grants to registered STREAMS and nothing else. This
// module's stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids
// (`uo.champ.started`, …) are disjoint sets, so no trigger here can be pushed
// through the engagement path at all: the tickle resolves to zero endpoints
// while the send log records it delivered.
//
// That is true of every sibling rule above and is a pre-existing defect, not
// one this rule introduces. What this rule declines to do is add a
// twenty-first instance of it. See EVENTS_PLAN.md Phase 11a.
channels: CHANNELS_CONTENT,
template_keys: bodies('champ.boss-killed'),
// The same half-hour as its `boss_up` twin, and on the SAME subject — the
// spawn — so an altar that pops and is cleared inside the window produces the
// walk or the fall, not both.
cooldown_seconds: 1800,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.server.up',
name: 'Shard — came online',
@@ -960,10 +1026,29 @@ const RULES = [
},
]
const RULE_GROUPS = [{
key: 'triggers-v1',
note: 'UO notifications stay off until an operator enables one',
rules: RULES,
}]
// A group is seeded ONCE, under its own settings guard. So a rule appended to an
// existing group reaches fresh installs and nothing else: every deployment that
// has already stamped `triggers-v1` is done with it forever, and the new rule
// would silently never arrive. That is Engagement Phase 11's seed-key finding,
// and core applied the same remedy in Events Phase 10 — a NEW key per addition,
// never an edit to an old one.
//
// So protocol 6's `uo.champ.boss_killed` rule ships as its own group rather than
// as a twenty-seventh entry above. `RULES` remains the whole declared set, which
// is what the "every declared trigger has exactly one rule" invariant reads.
const BOSS_KILLED = RULES.filter((r) => r.trigger_id === 'uo.champ.boss_killed')
const RULE_GROUPS = [
{
key: 'triggers-v1',
note: 'UO notifications stay off until an operator enables one',
rules: RULES.filter((r) => !BOSS_KILLED.includes(r)),
},
{
key: 'champ-boss-killed-v1',
note: 'The champion-falls notice, added with protocol 6; off like every other',
rules: BOSS_KILLED,
},
]
module.exports = { TEMPLATES, RULES, RULE_GROUPS }