docs(link): protocol 6 part b -- leases, participation, and what the walk found

Six files. `v6.md` gains sections 7-10: one version with two halves, the lease
plane, the participation ledger, and 11b's verification.

Three corrections to what the plan and the design of record assumed.

Phase 11b is FIVE repos, not four, and in the opposite direction from 11a's
correction: a lease's ledger row had no reconcile path anywhere, because the step
that made it names `core.lease` and that is core's own action. `website` joins.

EVENTS.md's §D frames the 258 `Config.Get` call sites as splitting into two
patterns. Measured on 57.4: of the 158 non-Bridge sites in `Scripts/`, roughly
eight are read live. The allowlist is not a curated subset of a large pool, it is
nearly the whole of what exists. And `Config.Set` has exactly one caller in the
entire tree, so on a stock shard a GM cannot drift a configuration lease even
deliberately -- which is why proving `drifted` needed a scaffolding verb.

§G's "participation attribution is now the largest piece of new UO work" closes,
and the live-config-lease row goes to built-with-one-key.

§10.1 records the defect the phase's own deferral found in 11a's shipped code:
`bridge.busy` answered 200 instead of 425 because the frame carried two `kind`
fields and parsers take the last. Unreachable in 11a by construction; produced on
the first collision here.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:32:11 -05:00
parent 1521c5af7e
commit eabaf2635f
6 changed files with 546 additions and 23 deletions

View File

@@ -69,6 +69,12 @@ Pin the version you built against and compare it to the header (or `/health.prot
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying
an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds
`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six
endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an
operator has not switched it on. See [`v6.md`](v6.md).
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
@@ -970,6 +976,89 @@ human just pressed a button for and can see the result of.
The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the
second removal of an entry is a no-op the shard is already happy to perform.
### The event plane — leases (Protocol 6)
**Off by default.** Every endpoint below answers **403** unless the operator has set
`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch:
enabling admin writes is consenting to staff moderation from a screen a human is looking at, and
enabling this is consenting to your world being changed and watched on a schedule, unattended.
A **lease** is a live configuration value held at a new setting for a bounded time. The shard
restores the baseline when the deadline passes **whether or not you are ever heard from again** —
so the worst case is a world back at baseline early, never one stuck changed indefinitely.
```json
GET /lease
→ 200 { "kind":"lease.list.ok", "leases":[
{ "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float",
"min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] }
POST /lease
{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000,
"untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" }
→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 }
POST /lease/release
{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" }
→ 200 { "kind":"lease.ok", "released":true, "current":"1000" }
```
Five things a caller needs:
- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take
effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply
cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and
"any config key" is not offered.
- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline
is measured against two clocks, and a shard whose clock runs fast would restore your lease the
moment it took it.
- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed
values at the other end; the text is so a compare-and-set is comparing what you sent.
- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you
held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an
error: the mechanism did its job, and only a human can decide what should happen next.
- **`held` means the shard still has a record of the lease, not that the value is still overridden.**
A lease whose deadline has fired stays listed with `expired: true` until you release it, so a
reconcile in that window does not read a working backstop as a lost resource. A shard restart, by
contrast, reverts every lease and clears the record — `held: false` is how you learn that.
### The event plane — participation (Protocol 6)
A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed
by **character serial**. The shard computes a score and you store it; the components ride along so
you can explain it.
```json
POST /participation
{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 }
→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false }
POST /participation/99/snapshot
{ "idempotencyKey":"…" }
→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5,
"participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17",
"seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000",
"firstMs":1788550182074, "lastMs":1788553782074 } ] }
POST /participation/99/close
→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 }
```
- **The area is a point and a radius, not a region name.** The most specific region containing an
event is routinely anonymous on a UO map — an active champion spawn registers a nameless region
over its own area — so a region-named venue would be undeclarable for exactly the places events
happen.
- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended
run the shard walks its members across game ticks rather than in one call, so a repeat arriving
mid-walk is answered **425**. Come back.
- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run
the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being
counted for it either way.
- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally
says so rather than quietly being short.
- **`webId`** is present only where the character's game account is linked to a website user. Most
characters carry none; treat its absence as ordinary.
### Help-page (support) queue
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
@@ -1165,7 +1254,8 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
| 200 | OK |
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
| 401 | Missing or invalid auth token |
| 404 | Not found (unknown account / character / id, or a not-linked account) |
| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard |
| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) |
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry |
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |