feat(events): one lease and the participation verbs (Phase 11b) #30

Merged
whitlocktech merged 1 commits from feature/events-p11b-leases-participation into edge 2026-09-05 04:10:16 +00:00
Member

EVENTS_PLAN.md Phase 11b, the UO half. No route added, no schema change, no MODULE_API bump.

The lease, and why the catalog has exactly one key in it

EVENTS.md §D describes the 258 Config.Get call sites as splitting into two patterns — cached at type initialisation, where a lease applies cleanly and does nothing, and read live, where it takes effect at once. Measured on ServUO 57.4:

Of the 158 non-Bridge call sites in Scripts/, roughly eight are read live.

So the allowlist is not a curated subset of a large pool. It is nearly the whole of what exists, and the catalog Phase 12 inherits will be short for reasons no amount of care can change. uo.playercaps.skillcap is the one that is both live and observable — CharacterCreation.cs reads it inside the per-character path — which is what "proven" has to mean here, since the failure an allowlist exists to prevent is a key that applies cleanly and changes nothing.

Three of the four callables have a shape where the obvious implementation is subtly wrong:

  • apply() sends a DURATION, not the deadline. An absolute time computed here and honoured there is a deadline measured against two clocks, and a shard running ten minutes fast would restore a ten-minute lease the instant it took it. untilMs still rides along, for a console that wants to say when the hold ends in the operator's own terms.
  • restore() turns lease.drifted into { ok: false, drifted: true, current }, never an error. Core records drift as a distinct successful outcome — the module did exactly what it was asked and found somebody else's value in place — while an error would put the row on the retry ladder and spend its attempts on a situation only a human can resolve. The sidecar answers it 200 for the same reason.
  • inForce() asks whether the shard still HOLDS the lease, never whether the value still matches. A changed value is drift, which restore() reports; answering "not in force" here would orphan the row first and tell the operator the lease vanished rather than that somebody moved it. It matters most for the case core could not see at all before website#193: a config lease is memory-only on the shard, so a restart reverts it and clears the record.

Participation

uo.participation.open ledgers the venue and starts the count; uo.participation.collect files the tally on its success envelope, which is the path Phase 10 built and this is the first module to use.

  • inspect, not change. Nothing in the world moves and no player can see it: the shard starts keeping a tally about a place. §K puts the default-off line between inspect and change, and a step that only watches is not one an operator should have to switch on before an event can record who came. It is still reversible: 'ledger', because the shard IS holding one of a bounded number of counting slots on this run's behalf — reversibility is about what a run owes, not how loud it was in taking it.
  • The venue is authored as a named place. place is sourced from uo.options.landmarks and resolved to map + point through the atlas, so an author picks "Felucca/Britain" and never types a coordinate. A place this shard's atlas does not know is a refusal the dry run can show them, not a 400 mid-run.
  • userId is resolved here and only when the shard could resolve it. It is a foreign key into users; core refuses anything that is not a positive integer rather than coercing, and a character serial passed there would either fail the insert or attribute somebody's attendance to a stranger who happened to hold that id. Most characters carry no webId at all.
  • The score is opaque to core and the components ride in meta. A results table that can say "forty minutes and three kills" beside a number is one an operator can defend when a player argues with it.

The one resource here that must NOT reconcile by boot stamp

⚠️ For a reviewer, because it is the single place this file departs from wave 1.

Every other resource in uoEventActions.js uses reconcileByBootId, and that works because a town-crier line and an event-owned news article live in shard memory — a changed bootId is the proof they are gone.

The participation ledger is written into the world save, specifically so that it survives the restart the boot stamp would report it lost by. Stamping it would orphan the one resource the phase went to the trouble of persisting. So uo.participation.open asks instead, and only an explicit 404 takes a row out; a shard that is down, slow or refusing has not said the ledger is gone. There is a test that asserts its reconcile is not reconcileByBootId and that its resource carries no stamp, so nothing downstream can be tempted to compare one.

Visibility

lease.applied and lease.expired are deliberately left off KIND_FEATURE, on the same reasoning that keeps account.login.result off it. They are operational records of the website changing this shard's configuration — which key, from what to what, on whose run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs; mapping them would mean choosing a feature an operator could then widen, and there is no rung below admin these belong on. The comment says so, so a later reader does not "fix" the omission.

The participation ledger emits no stream frame at all: a live feed of who is standing where would be a location tracker, and the ledger's whole justification is that it answers one bounded question about one run.

Verification

  • npm test --prefix server588 pass, 0 fail (17 new). npm test --prefix client — 42 pass. npm run build --prefix client green. check:imports clean.
  • Every claim in servuo-plugins#22's table was collected through this module's own client shapes on the live rig — a real ServUO, the release sidecar, and the tally surviving a shard restart mid-run.

The new suite is written against the places the obvious implementation is wrong rather than against the calls happening, which a rig proves better: drift-not-error, inForce is not a comparison, apply sends a duration, the ledger is not boot-stamped, and a webId that is not a positive integer resolves to nothing at all.

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#22 · Sidecar: RunicGateway/link#37 · Core: RunicGateway/website#193 · Docs: RunicGateway/docs#220

🤖 Generated with Claude Code

`EVENTS_PLAN.md` Phase 11b, the UO half. **No route added, no schema change, no `MODULE_API` bump.** ## The lease, and why the catalog has exactly one key in it `EVENTS.md` §D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at type initialisation, where a lease applies cleanly and does **nothing**, and read live, where it takes effect at once. Measured on ServUO 57.4: > Of the **158** non-Bridge call sites in `Scripts/`, roughly **eight** are read live. So the allowlist is not a curated subset of a large pool. It is nearly the whole of what exists, and the catalog Phase 12 inherits will be short for reasons no amount of care can change. `uo.playercaps.skillcap` is the one that is both live *and* observable — `CharacterCreation.cs` reads it inside the per-character path — which is what "proven" has to mean here, since the failure an allowlist exists to prevent is a key that applies cleanly and changes nothing. Three of the four callables have a shape where the obvious implementation is subtly wrong: - **`apply()` sends a DURATION, not the deadline.** An absolute time computed here and honoured there is a deadline measured against **two clocks**, and a shard running ten minutes fast would restore a ten-minute lease the instant it took it. `untilMs` still rides along, for a console that wants to say when the hold ends in the operator's own terms. - **`restore()` turns `lease.drifted` into `{ ok: false, drifted: true, current }`, never an error.** Core records drift as a distinct *successful* outcome — the module did exactly what it was asked and found somebody else's value in place — while an error would put the row on the retry ladder and spend its attempts on a situation only a human can resolve. The sidecar answers it **200** for the same reason. - **`inForce()` asks whether the shard still HOLDS the lease, never whether the value still matches.** A changed value is drift, which `restore()` reports; answering "not in force" here would orphan the row first and tell the operator the lease vanished rather than that somebody moved it. It matters most for the case core could not see at all before `website#193`: a config lease is memory-only on the shard, so a restart reverts it **and** clears the record. ## Participation `uo.participation.open` ledgers the venue and starts the count; `uo.participation.collect` files the tally on its success envelope, which is the path Phase 10 built and this is the first module to use. - **`inspect`, not `change`.** Nothing in the world moves and no player can see it: the shard starts keeping a tally about a place. §K puts the default-off line between `inspect` and `change`, and a step that only watches is not one an operator should have to switch on before an event can record who came. It is still `reversible: 'ledger'`, because the shard IS holding one of a bounded number of counting slots on this run's behalf — reversibility is about what a run owes, not how loud it was in taking it. - **The venue is authored as a named place.** `place` is sourced from `uo.options.landmarks` and resolved to map + point through the atlas, so an author picks "Felucca/Britain" and never types a coordinate. A place this shard's atlas does not know is a refusal the **dry run** can show them, not a 400 mid-run. - **`userId` is resolved here and only when the shard could resolve it.** It is a foreign key into `users`; core refuses anything that is not a positive integer rather than coercing, and a character serial passed there would either fail the insert or attribute somebody's attendance to a stranger who happened to hold that id. Most characters carry no `webId` at all. - **The score is opaque to core and the components ride in `meta`.** A results table that can say "forty minutes and three kills" beside a number is one an operator can defend when a player argues with it. ## The one resource here that must NOT reconcile by boot stamp ⚠️ **For a reviewer**, because it is the single place this file departs from wave 1. Every other resource in `uoEventActions.js` uses `reconcileByBootId`, and that works because a town-crier line and an event-owned news article live in **shard memory** — a changed `bootId` *is* the proof they are gone. The participation ledger is written into the **world save**, specifically so that it survives the restart the boot stamp would report it lost by. Stamping it would orphan the one resource the phase went to the trouble of persisting. So `uo.participation.open` asks instead, and only an explicit **404** takes a row out; a shard that is down, slow or refusing has not said the ledger is gone. There is a test that asserts its `reconcile` is not `reconcileByBootId` and that its resource carries no stamp, so nothing downstream can be tempted to compare one. ## Visibility `lease.applied` and `lease.expired` are **deliberately left off `KIND_FEATURE`**, on the same reasoning that keeps `account.login.result` off it. They are operational records of the *website* changing this shard's configuration — which key, from what to what, on whose run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs; mapping them would mean choosing a feature an operator could then widen, and there is no rung below admin these belong on. The comment says so, so a later reader does not "fix" the omission. The participation ledger emits no stream frame at all: a live feed of who is standing where would be a location tracker, and the ledger's whole justification is that it answers one bounded question about one run. ## Verification - `npm test --prefix server` — **588 pass, 0 fail** (17 new). `npm test --prefix client` — 42 pass. `npm run build --prefix client` green. `check:imports` clean. - Every claim in `servuo-plugins#22`'s table was collected through this module's own client shapes on the live rig — a real ServUO, the release sidecar, and the tally surviving a shard restart mid-run. The new suite is written against the places the obvious implementation is wrong rather than against the calls happening, which a rig proves better: drift-not-error, `inForce` is not a comparison, `apply` sends a duration, the ledger is not boot-stamped, and a `webId` that is not a positive integer resolves to nothing at all. > `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#22 · Sidecar: RunicGateway/link#37 · Core: RunicGateway/website#193 · Docs: RunicGateway/docs#220 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-05 04:07:26 +00:00
feat(events): one lease and the participation verbs (Phase 11b)
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 40s
88bfe9310e
The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.

`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.

Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.

`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit c11c130438 into edge 2026-09-05 04:10:16 +00:00
whitlocktech deleted branch feature/events-p11b-leases-participation 2026-09-05 04:10:17 +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#30
No description provided.