feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
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:
@@ -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 }
|
||||
|
||||
@@ -600,6 +600,42 @@ const COME_ONLINE = [
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Protocol 6, and the reason the kind exists at all. Its first consumer is not
|
||||
// a mail rule but an EVENT PHASE CONDITION: `{ on: 'uo.champ.boss_killed',
|
||||
// where: [...], count: 1 }` is how an author says "move to the next phase when
|
||||
// the boss falls", and a condition is expressed over a trigger firing. That is
|
||||
// also why it is declared here rather than only ingested — a kind nothing
|
||||
// declares is a kind no event can wait on.
|
||||
id: 'uo.champ.boss_killed',
|
||||
label: 'A champion boss was defeated',
|
||||
description: 'Players brought down a champion spawn boss.',
|
||||
kind: 'event',
|
||||
subjectKey: 'spawnSerial',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||
description: 'The spawn controller, or the boss itself where the shard could not name an altar. Also the cooldown subject.' },
|
||||
{ name: 'bossName', type: 'string', required: true, example: 'Semidar',
|
||||
description: 'The boss that fell.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||||
description: 'champion or sea.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570 (Destard)',
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'killerName', type: 'string', required: false, example: 'Aldric',
|
||||
description: 'Who struck the last blow, when the shard names one.' },
|
||||
{ name: 'damagerCount', type: 'int', required: false, example: 14,
|
||||
description: 'How many players did damage to it. The names themselves are staff-only and are deliberately not offered here.' },
|
||||
{ name: 'damagerNote', type: 'string', required: false, example: ' 14 players fought it.',
|
||||
description: 'A trailing sentence, LEADING SPACE included, or empty when nobody is credited.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.server.up',
|
||||
label: 'The shard came online',
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user