docs(link): protocol 6 as built — a guarantee, and the kind that pays for it

`link/v6.md` is the spec of record. It is the first bump that adds a promise
rather than data: a command carrying an `idempotencyKey` is executed at most once,
and a repeat is answered with the original reply.

Also in this PR:

- `INTEGRATION.md` — §2 gains v6, the write plane gains a "retrying a command
  safely" section with the four rules a caller needs, §7 gains 425 and the
  paragraph on why it is not 409, and the event catalog gains `champ.boss.killed`.
- `PLAN.md` — §5.5 records that a per-KILL damage table needs no override even
  though a per-HIT event does, which is the observation the new kind rests on;
  §7 documents the key on the inbound frames.
- `EVENTS.md` — §A row 7 and two §G capability rows close, and the failure table's
  "an action succeeds but the ack is lost" loses its "does not exist today".
- `EVENTS_PLAN.md` — Phase 11 splits into 11a and 11b, and `installer` leaves the
  phase: PLAN.md §7.4 made sure no protocol version is hardcoded there, so the
  bundle gate is version-agnostic and needed no change for 5 either.

The live walk's defect is written up in v6.md §6.1 because the naive check
confirms it: an active ChampionSpawn registers an unnamed region over its own
spawn area, so the innermost region containing a champion boss is guaranteed to
have no name — and region registration is deferred, so a lookup at spawn time
answers "Britain" while one at the kill does not.

CRLF preserved throughout; links and anchors checked by hand, this repo has no CI.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:57:42 -05:00
parent 6cb54ecdb7
commit 8b4096dd18
5 changed files with 569 additions and 25 deletions

View File

@@ -1025,7 +1025,7 @@ site, cued to a GM, completed, recorded.
**Trap worth writing into the action declarations.** These three verbs have *different* idempotency.
`towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is
not** — a retry is a second announcement to everyone online — and there is no idempotency key on the
wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
wire until P11a. 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
@@ -1246,25 +1246,117 @@ push; the ceiling on `run.failed` proved to exclude a moderator.
---
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`)
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `docs`)
**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked.
**§N1 answered 2026-09-01 — no longer gated.** A protocol bump, and the shape TEAMS Phase 1 already walked.
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a
repeat with the original result; a lease deadline timer that restores baseline **without being
asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind.
**Split into 11a and 11b (org lead, 2026-09-04).** Three independent mechanisms across four repos,
each with a different thing to prove on a live ServUO, is one review and one walk too many. They land
as **one protocol version**: 11a bumps to 6, and 11b amends 6 **in place** on `edge` — the same rule
the org lead set for `MODULE_API_VERSION` on 2026-09-03, and it applies because 6 will not reach
`main` until the events cutover. The bundle CI therefore never sees two numbers.
**`installer` is NOT in this phase, and the line above that said so was wrong.** `PLAN.md` §7.4 made
sure of it deliberately: no protocol version is hardcoded anywhere in the installer. `bundle.rs`
reads the number out of the bundle document, and `bundle.yml`'s Gate 1 reads both halves out of the
released artefacts and refuses a mismatch — version-agnostic, so it needed no change for 5 either.
And `link`/`servuo-plugins` are on `edge`, so nothing is released or bundled until the cutover.
#### Phase 11a — the idempotency key, and `champ.boss.killed`
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers
a repeat with the original result; `champ.boss.killed` as a first-class kind.
- **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar
and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to
compose.
- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served.
- **`installer`:** joins the phase because of the pairing above.
- **Sidecar:** `PROTOCOL_VERSION` bumped; the key carried; the new kind stored and served.
- **`module-uo`:** the key sent on every event-driven write, `uo.broadcast` made retryable, and the
new kind mapped for visibility and declared as a trigger.
- **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`.
**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both
are safety properties, not features.
**Ships:** the game side can refuse a duplicate command. A safety property, not a feature — and the
precondition every world verb in Phase 12 is waiting on.
**Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new
code is live; a deliberate duplicate command; a lease whose website is killed before it expires,
proving baseline returns anyway.
code is live; a deliberate duplicate command, checked against the shard's own audit trail rather than
against the reply.
> **Built.** `PROTOCOL_VERSION` and `overlay.toml` both **6**, in the same pair of PRs. The whole
> sidecar change is one constant and one status mapping; the whole guarantee lives on the shard,
> which is the only place it can, because the shard is where the world write happens.
>
> **Seven decisions (org lead, 2026-09-04), all as recommended.** The 11a/11b split; participation
> keyed by character serial; the ledger persisted in the world save; leases as machinery plus one
> proven key; reserve-on-receipt with `bridge.busy` for an in-flight repeat; a TTL-plus-cap with a
> loud eviction; and top damagers at a `staff` ceiling. The last three are 11a's.
>
> **What the phase bought immediately:** `uo.broadcast` stopped being un-retryable. Phase 9 shipped it
> answering `retry: false` to *everything* including a 503 from a restarting shard, with a comment
> naming the line that would change. This is that line. It now 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 merely 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, which is why Phase 9 called them safe to retry. But
> `news.add` with `announce: true` makes the criers proclaim the article's title on every post, so a
> retry replaced the article silently and proclaimed it **again**. The key stops the second
> proclamation. Read in the plugin, not inferred: `BridgeNews` removes the old entry, inserts the
> new one, and then announces.
>
> **The live walk found one defect, and the naive check confirms it.** A champion killed in the
> middle of Britain emitted a frame with **no region**. An active `ChampionSpawn` registers a
> `ChampionSpawnRegion` over its spawn area with a **null name** and the town region as its parent,
> so the most specific region containing a champion boss is the one region on the map guaranteed to
> be nameless — and `Mobile.Region` hides that by falling back to the map's unnamed default rather
> than to null. Region registration is deferred, too, so a lookup at spawn time answers `"Britain"`
> and one at the kill does not: the probe printed `"Britain"` on every run, including the ones whose
> frame carried nothing. Fixed by walking outward to the nearest NAMED ancestor, which is the general
> answer — house regions, dungeon sub-regions and guarded-zone overlays are all anonymous children of
> somewhere a player would name.
>
> **Two findings that are rules rather than facts about events:**
>
> - **A trigger is not a stream, and in this module they are disjoint sets.** Push delivery is keyed
> on the subscription id, which core's catalog grants only to registered STREAMS. `module-uo`'s
> stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids (`uo.champ.started`, …) share
> no member, so **no** engagement rule in this module can push — the tickle resolves to zero
> endpoints while the send log records success. That is the Phase 10 defect, pre-existing here in
> twenty rules. This phase does not fix them; it declines to add a twenty-first, so its rule ships
> `['email','inapp']` with a comment saying why. **Flagged for the org lead as its own change.**
> - **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 reach fresh installs and nothing else.
> `champ-boss-killed-v1` is its own group — the same remedy core applied in Phase 10, and the third
> application of Engagement Phase 11's seed-key finding.
>
> **What the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat cannot
> arrive while the original runs — the Core thread takes one inbound line at a time — so the state is
> unreachable on a live shard until a handler defers. Implemented, with `Hold`/`Complete` as the door,
> and unit-tested at the sidecar's mapping on all three responders. **11b's leases are the first
> thing that can produce it**, and proving it belongs in that walk.
>
> See [`../link/v6.md`](../link/v6.md) for the spec of record.
#### Phase 11b — lease deadlines and the participation ledger
- **Plugin:** a lease deadline timer that restores baseline **without being asked**, with
compare-and-set restore reporting `drifted`; a run-scoped participation ledger.
- **Leases land as machinery plus ONE proven key** (org lead): the generic registry, the deadline
timer, CAS restore and `lease.list` reconcile, proved end to end against one verified live-read
`Config.Get` key. The curated allowlist and its boot-time self-check stay in Phase 12, where the
rest of the lease surface is.
- **The participation ledger counts presence in a declared area plus kill credit inside it, keyed by
character serial** — matching `module-uo`'s existing Teams `memberKey`, so one module speaks one
member vocabulary. The plugin computes the score; core stores an opaque number it never interprets.
- **The ledger is persisted in the world save**, which makes it the Bridge's first persisted state
ever. A run spans hours and a restart mid-event is realistic; an in-memory tally would regress the
score after one, and the only ways to paper over that are a high-water rule in core (which must
stay game-agnostic) or a per-run offset in the module.
- **Protocol 6 is amended in place**, not bumped to 7. See the split note above.
**Ships:** the game side can put the world back on its own, and can say who took part.
**Verify:** a lease whose website is killed before it expires, proving baseline returns anyway; a
deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a tally that survives
a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes
reachable.
---
@@ -1396,7 +1488,7 @@ flowchart TD
P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
P5 --> P10["P10 · integrations"]
P9 --> P10
P0 -.->|"§N1 answered"| P11["P11 · protocol bump"]
P0 -.->|"§N1 answered"| P11["P11a+b · protocol 6"]
P8 --> P11
P11 --> P12["P12 · UO world verbs"]
P7 --> P13["P13 · authoring UI"]