feat(events): send the idempotency key, declare champ.boss.killed (Phase 11a) #29

Merged
whitlocktech merged 1 commits from feature/protocol-v6-idempotency into edge 2026-09-04 23:07:04 +00:00
Member

EVENTS_PLAN.md Phase 11a, the website's half of protocol 6. No route added, no schema change.

uo.broadcast stops being un-retryable, and that is the whole point of the phase

Phase 9 shipped it answering retry: false to everything — including a 503 from a shard that was merely restarting — and said so in a comment naming the line that would change once the wire could refuse a repeat:

"Phase 11 puts an idempotency key on the wire and this line is what changes."

This is that line. Every event-driven write now carries the step's idempotencyKey, the shard executes a key at most once, and a retry collects the answer the first attempt never delivered rather than announcing to everyone twice.

The classification is now sidecarFailure's — the same helper its two siblings already used — so the hand-rolled variant that forced every outcome terminal is gone rather than merely re-tuned. That is most of the diff in uoEventActions.js.

425 is the new status in that path (bridge.busy: a command under this key is still in flight). It is not in PERMANENT_STATUSES and classify() falls through to retry, so it needed no arm — but it is named in a comment so a future tightening of that list has to decide about it deliberately.

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, which is why Phase 9 called them safe to retry. True for the crier. Not quite true for news: news.add with announce: true makes the criers proclaim the article's title, and BridgeNews removes the old entry, inserts the new one and then announces — so a retry replaced the article silently and proclaimed it again. The key stops the second proclamation. Read in the plugin rather than inferred.

champ.boss.killed

  • Mapped to the champs feature. Not optional: rule 2 fails an unmapped kind closed to admin-only, and a champion falling is exactly what the public board is for.
  • damagers is a nested staff field rule. The split is the point — a shard announces that its champion fell without publishing a ranked roll of who was strong enough to fell it. One rule covers the whole table, and acct/webId inside each entry stay admin-locked by suffix whatever an admin sets it to. killer is deliberately not listed: one actor, whose blow everyone present saw, and the same disclosure mob.killed has made on the public activity feed for years.
  • uo.champ.boss_killed is declared as a trigger, which is what makes it usable as an event phase condition — a condition is written over a trigger firing, so a kind nothing declares is a kind no event can wait on. "Advance when the boss falls" is the canonical UO phase boundary and it did not exist.
  • The trigger carries damagerCount, never a damager name. A trigger variable is interpolated into mail an operator may address to every subscriber, so a name in the trigger data would undo the staff field rule one layer up — a distinct hole from the SSE path that rule guards, reachable without touching the visibility config at all. There is a test that asserts no damager name or account appears anywhere in the firing.
  • Its subject is the SPAWN, matching uo.champ.boss_up, so a cooldown on one altar counts the boss going up and coming down as one subject. A kill the shard could not attribute falls back to the boss's own serial — a subject that exists exactly once, which is all a cooldown needs.

Two things the seeds forced, both of them rules

A new rule needs a new seed GROUP, never an appended one. triggers-v1 is stamped once under a settings guard, so a twenty-seventh entry would have reached fresh installs and nothing else. champ-boss-killed-v1 is its own group — the same remedy core applied in Events Phase 10, and the third application of Engagement Phase 11's seed-key finding. Its test now asserts the two groups partition RULES exactly.

A trigger is not a stream, and in this module they are disjoint sets. ⚠️ For a reviewer. Push delivery is keyed on the subscription id, which core's notificationChannelPrefs.catalog grants only to registered STREAMS. This module's stream ids (champ.start, idoc.warning, vendor.sale, …) and its trigger ids (uo.champ.started, uo.house.idoc_warning, …) share no member. So no engagement rule here can push: publishToUsers resolves zero endpoints while pushChannel.deliver answers ok: true, 'tickle published'.

That is the Events Phase 10 defect, pre-existing here in twenty rules that list CHANNELS_BROADCAST. This PR does not fix them — it declines to add a twenty-first. The new rule ships CHANNELS_CONTENT (['email','inapp']), a constant that exists to carry that reason rather than to share CHANNELS_OWNER's value. Flagged for the org lead as its own change.

Verification

  • npm test --prefix server571 pass, 0 fail (9 new). npm test --prefix client — 42 pass. npm run build --prefix client green. check:imports clean.

  • The real wire bytes, not a fixture. The frame captured off the live sidecar during the plugin's rig walk was fed back through this module: trigger uo.champ.boss_killed with location: "Felucca 1496, 1628 (Britain)" and damagerCount: 2; projected damagers absent for anonymous and player, present for staff, and acct stripped from every actor at every rung below admin.

  • check:swagger reports STALE on Windows and is a known false positive — regenerating produces a byte-identical file apart from line endings, and this PR adds no route.

  • AI-assisted: Claude Code (Opus 5).

Plugin: RunicGateway/servuo-plugins#21 · Sidecar: RunicGateway/link#36 · Docs: RunicGateway/docs#219

🤖 Generated with Claude Code

`EVENTS_PLAN.md` Phase 11a, the website's half of protocol 6. **No route added, no schema change.** ## `uo.broadcast` stops being un-retryable, and that is the whole point of the phase Phase 9 shipped it answering `retry: false` to *everything* — including a 503 from a shard that was merely restarting — and said so in a comment naming the line that would change once the wire could refuse a repeat: > *"Phase 11 puts an idempotency key on the wire and this line is what changes."* This is that line. Every event-driven write now carries the step's `idempotencyKey`, the shard executes a key at most once, and a retry collects the answer the first attempt never delivered rather than announcing to everyone twice. The classification is now `sidecarFailure`'s — **the same helper its two siblings already used** — so the hand-rolled variant that forced every outcome terminal is *gone* rather than merely re-tuned. That is most of the diff in `uoEventActions.js`. **425 is the new status in that path** (`bridge.busy`: a command under this key is still in flight). It is not in `PERMANENT_STATUSES` and `classify()` falls through to retry, so it needed no arm — but it is named in a comment so a future tightening of that list has to decide about it deliberately. ## 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, which is why Phase 9 called them safe to retry. True for the crier. **Not quite true for news:** `news.add` with `announce: true` makes the criers proclaim the article's title, and `BridgeNews` removes the old entry, inserts the new one and *then* announces — so a retry replaced the article silently and proclaimed it **again**. The key stops the second proclamation. Read in the plugin rather than inferred. ## `champ.boss.killed` - **Mapped to the `champs` feature.** Not optional: rule 2 fails an unmapped kind closed to admin-only, and a champion falling is exactly what the public board is for. - **`damagers` is a nested `staff` field rule.** The split is the point — a shard announces that its champion fell without publishing a ranked roll of who was strong enough to fell it. One rule covers the whole table, and `acct`/`webId` inside each entry stay admin-locked by suffix whatever an admin sets it to. `killer` is deliberately *not* listed: one actor, whose blow everyone present saw, and the same disclosure `mob.killed` has made on the public activity feed for years. - **`uo.champ.boss_killed` is declared as a trigger**, which is what makes it usable as an event **phase condition** — a condition is written over a trigger firing, so a kind nothing declares is a kind no event can wait on. *"Advance when the boss falls"* is the canonical UO phase boundary and it did not exist. - **The trigger carries `damagerCount`, never a damager name.** A trigger variable is interpolated into mail an operator may address to every subscriber, so a name in the trigger data would undo the `staff` field rule one layer up — a distinct hole from the SSE path that rule guards, reachable without touching the visibility config at all. There is a test that asserts no damager name or account appears anywhere in the firing. - **Its subject is the SPAWN**, matching `uo.champ.boss_up`, so a cooldown on one altar counts the boss going up and coming down as one subject. A kill the shard could not attribute falls back to the boss's own serial — a subject that exists exactly once, which is all a cooldown needs. ## Two things the seeds forced, both of them rules **A new rule needs a new seed GROUP, never an appended one.** `triggers-v1` is stamped once under a settings guard, so a twenty-seventh entry would have reached fresh installs and nothing else. `champ-boss-killed-v1` is its own group — the same remedy core applied in Events Phase 10, and the third application of Engagement Phase 11's seed-key finding. Its test now asserts the two groups partition `RULES` exactly. **A trigger is not a stream, and in this module they are disjoint sets.** ⚠️ **For a reviewer.** Push delivery is keyed on the subscription id, which core's `notificationChannelPrefs.catalog` grants only to registered STREAMS. This module's stream ids (`champ.start`, `idoc.warning`, `vendor.sale`, …) and its trigger ids (`uo.champ.started`, `uo.house.idoc_warning`, …) share **no member**. So no engagement rule here can push: `publishToUsers` resolves zero endpoints while `pushChannel.deliver` answers `ok: true, 'tickle published'`. That is the Events Phase 10 defect, **pre-existing here in twenty rules** that list `CHANNELS_BROADCAST`. This PR does not fix them — it declines to add a twenty-first. The new rule ships `CHANNELS_CONTENT` (`['email','inapp']`), a constant that exists to carry that reason rather than to share `CHANNELS_OWNER`'s value. **Flagged for the org lead as its own change.** ## Verification - `npm test --prefix server` — **571 pass, 0 fail** (9 new). `npm test --prefix client` — 42 pass. `npm run build --prefix client` green. `check:imports` clean. - **The real wire bytes, not a fixture.** The frame captured off the live sidecar during the plugin's rig walk was fed back through this module: trigger `uo.champ.boss_killed` with `location: "Felucca 1496, 1628 (Britain)"` and `damagerCount: 2`; projected `damagers` **absent** for `anonymous` and `player`, present for `staff`, and `acct` stripped from every actor at every rung below admin. - `check:swagger` reports STALE on Windows and is a **known false positive** — regenerating produces a byte-identical file apart from line endings, and this PR adds no route. - [x] AI-assisted: Claude Code (Opus 5). Plugin: RunicGateway/servuo-plugins#21 · Sidecar: RunicGateway/link#36 · Docs: RunicGateway/docs#219 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-04 23:01:36 +00:00
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
dc13515927
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>
whitlocktech merged commit bf9a702cfa into edge 2026-09-04 23:07:04 +00:00
whitlocktech deleted branch feature/protocol-v6-idempotency 2026-09-04 23:07:05 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#29
No description provided.