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:
@@ -272,7 +272,7 @@ What is genuinely not there, stated as what the codebase says rather than as ass
|
||||
| 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. |
|
||||
| 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. |
|
||||
| 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. |
|
||||
| 7 | **The command path is not durable and has no idempotency.** | `rpc.rs`: an in-memory pending map, a flat 10-second timeout, three correlation fields, no replay. A command whose ack is lost is indistinguishable from one that never applied. |
|
||||
| 7 | ~~**The command path is not durable and has no idempotency.**~~ **Closed by protocol 6** (Phase 11a). `rpc.rs` is unchanged and still an in-memory pending map with a flat timeout — the replay was never the sidecar's to do. A command may now carry an `idempotencyKey`, and the SHARD executes a key at most once, answering a repeat with the original reply restamped for the retry's correlation id. Which is the only place the promise can live: the shard is where the world write happens. See [`../link/v6.md`](../link/v6.md) §2.1. |
|
||||
| 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. |
|
||||
| 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. |
|
||||
| 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. |
|
||||
@@ -1094,7 +1094,7 @@ a capability exists.
|
||||
| Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. |
|
||||
| Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. |
|
||||
| Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. |
|
||||
| Detect a boss defeated | 🔗 | Inferable from `champ.update` going `bossUp` true → false plus `mob.killed`. Workable and slightly fragile; a dedicated `champ.boss.killed` kind is a small honest addition worth doing alongside anything else on the wire. |
|
||||
| Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. |
|
||||
| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. |
|
||||
| Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. |
|
||||
| Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. |
|
||||
@@ -1106,7 +1106,7 @@ a capability exists.
|
||||
| Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. |
|
||||
| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. |
|
||||
| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. |
|
||||
| Idempotent command application | 🔧 📡 | Nothing on the wire carries an idempotency key and `rpc.rs` has no replay. **Required before any world write ships.** |
|
||||
| Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. |
|
||||
| Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. |
|
||||
| Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. |
|
||||
|
||||
@@ -1434,7 +1434,7 @@ controller stamps it from the session.
|
||||
| **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. |
|
||||
| **The sidecar is unreachable** | Every client call returns `{ ok: false }` rather than throwing — `uoLinkClient` never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state. |
|
||||
| **The module is disabled or fails to boot** | `installed_modules` records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails `terminal` with the module named, and the run degrades rather than claiming success. **Never a silent skip.** |
|
||||
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* |
|
||||
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe*, and as of protocol 6 (Phase 11a) it exists: proved on the rig with three broadcasts under two keys producing **two** rows in the shard's own audit trail. It has one bound worth knowing — the plugin remembers a key for an hour, capped at 4096, and says so loudly if the cap ever evicts a live one. |
|
||||
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
||||
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
||||
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
|
||||
@@ -1768,7 +1768,8 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
|
||||
| **P8** ✓ | The resource ledger, leases and generated cleanup | `website` `docs` |
|
||||
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
|
||||
| **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |
|
||||
| **P11** | *N1 answered.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` |
|
||||
| **P11a** | *N1 answered.* Protocol 6: the idempotency key, and `champ.boss.killed` | `servuo-plugins` `link` `module-uo` `docs` |
|
||||
| **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` |
|
||||
| **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` |
|
||||
| **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` |
|
||||
| **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` |
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user