fix(events): give a lease's ledger row a reconcile path (Phase 11b) #193

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

EVENTS_PLAN.md Phase 11b. The plan said Phase 11 was four repos and this is the fifth, in the opposite direction from 11a's correction: 11a dropped installer because nothing there hardcodes a protocol version, and 11b adds website because a lease's ledger row turned out to have no reconcile path anywhere.

No route, no schema change, no MODULE_API_VERSION bump.

The hole, and why nothing failed to say so

cleanup.js's reconcileModule() resolves a resource row to the action of the step that made it, then calls that action's reconcile(). For a lease that action is core.lease — a core action, on a path a module cannot register anything on, and one that declared no reconcile().

So every override row came back unanswered for the life of the run. That is the correct posture for "I could not ask", and it was silently wrong here: nobody was being asked. A lease the shard had quietly dropped stayed in the ledger as live until teardown went hunting a baseline nobody was holding.

It is not a hypothetical drop. A UO config lease is memory-only on the shard by design — nothing calls Config.Save(), so a restart is a free restore — which means the resource genuinely vanishes on a restart with no message and no boot-stamp anyone could compare. That was invisible to core until now.

inForce(), and why it is not read() with a comparison

Two pieces, both small:

  • core.lease gains a reconcile().
  • registerEventLeases gains an optional inForce()"does the game side still have any record of this hold?"

The obvious implementation is read() and compare against payload.applied, and it is wrong. A value that differs from what the run applied is DRIFT, and drift is teardown's verdict to deliver through restore() so the row lands drifted with the current value beside it — the one signal that tells an operator somebody moved their value rather than that it disappeared. A reconcile that inferred absence from a changed value would orphan the row first and destroy it. The two questions have different answers on purpose, and §F now says so.

Optional, and the fallback is core's posture everywhere else: only an explicit { ok: true, held: false } takes a row out. A throw, a timeout, an unrecognised shape, and a lease that declares no inForce() at all leave the ledger exactly as it was. A module that cannot answer keeps core believing its own ledger, which is the pre-Phase-8 behaviour and a capability its deployment does without rather than a boot it fails.

allEventLeases() strips the new callable like the other three: these objects leave the process, and the browser's whole relationship with a lease is naming one by id.

Tests

npm test2003 pass, 0 fail (5 new; 89 skipped are the live-DB ones).

  • the hole itself, asserted from the outside — a core.lease step with an override row and a lease answering held: false now reports { asked: 1, orphaned: 1 } where it previously reported unanswered
  • a lease the shard still has a record of stays confirmed
  • six unanswerable shapes leave the row alone, and the list deliberately includes the drift shape (held: true with a changed current) beside the shrugs, because that is the case the wrong implementation would have got wrong
  • inForce() is optional, is refused when present but not callable, and is stripped from the catalog

One flake seen once and green on re-run: eventRecurrence.test.js's bounded-expansion case is timing-sensitive under a loaded parallel run. Not touched by this change.

Test-harness note for reviewers. loadModule() rewrites index.js in place and clears the loader from the require cache, but not the module file it goes on to require — so a second loadModule('demo', …) in one test silently re-registers the first source. The new test uses three module ids. This cost a cycle and is worth knowing.

Not in scope

This is only the reconcile path. The lease itself, its deadline, its compare-and-set restore and the participation ledger are the shard's and the module's — servuo-plugins#22 and Module-uo#30.

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

Plugin: RunicGateway/servuo-plugins#22 · Sidecar: RunicGateway/link#37 · Module: RunicGateway/Module-uo#30 · Docs: RunicGateway/docs#220

🤖 Generated with Claude Code

`EVENTS_PLAN.md` Phase 11b. **The plan said Phase 11 was four repos and this is the fifth**, in the opposite direction from 11a's correction: 11a dropped `installer` because nothing there hardcodes a protocol version, and 11b adds `website` because a lease's ledger row turned out to have no reconcile path anywhere. No route, no schema change, no `MODULE_API_VERSION` bump. ## The hole, and why nothing failed to say so `cleanup.js`'s `reconcileModule()` resolves a resource row to the action of the **step that made it**, then calls that action's `reconcile()`. For a lease that action is **`core.lease`** — a *core* action, on a path a module cannot register anything on, and one that declared no `reconcile()`. So every `override` row came back `unanswered` for the life of the run. That is the correct posture for "I could not ask", and it was silently wrong here: nobody was being asked. A lease the shard had quietly dropped stayed in the ledger as **live** until teardown went hunting a baseline nobody was holding. It is not a hypothetical drop. A UO config lease is **memory-only on the shard by design** — nothing calls `Config.Save()`, so a restart is a free restore — which means the resource genuinely vanishes on a restart with no message and no boot-stamp anyone could compare. That was invisible to core until now. ## `inForce()`, and why it is not `read()` with a comparison Two pieces, both small: - `core.lease` gains a `reconcile()`. - `registerEventLeases` gains an **optional `inForce()`** — *"does the game side still have any record of this hold?"* The obvious implementation is `read()` and compare against `payload.applied`, and it is wrong. **A value that differs from what the run applied is DRIFT**, and drift is teardown's verdict to deliver through `restore()` so the row lands `drifted` with the current value beside it — the one signal that tells an operator somebody moved their value rather than that it disappeared. A reconcile that inferred absence from a changed value would orphan the row *first* and destroy it. The two questions have different answers on purpose, and §F now says so. Optional, and the fallback is core's posture everywhere else: **only an explicit `{ ok: true, held: false }` takes a row out.** A throw, a timeout, an unrecognised shape, and a lease that declares no `inForce()` at all leave the ledger exactly as it was. A module that cannot answer keeps core believing its own ledger, which is the pre-Phase-8 behaviour and a capability its deployment does without rather than a boot it fails. `allEventLeases()` strips the new callable like the other three: these objects leave the process, and the browser's whole relationship with a lease is naming one by id. ## Tests `npm test` — **2003 pass, 0 fail** (5 new; 89 skipped are the live-DB ones). - **the hole itself, asserted from the outside** — a `core.lease` step with an `override` row and a lease answering `held: false` now reports `{ asked: 1, orphaned: 1 }` where it previously reported `unanswered` - a lease the shard still has a record of stays `confirmed` - **six unanswerable shapes leave the row alone**, and the list deliberately includes the *drift* shape (`held: true` with a changed `current`) beside the shrugs, because that is the case the wrong implementation would have got wrong - `inForce()` is optional, is refused when present but not callable, and is stripped from the catalog One flake seen once and green on re-run: `eventRecurrence.test.js`'s bounded-expansion case is timing-sensitive under a loaded parallel run. Not touched by this change. > **Test-harness note for reviewers.** `loadModule()` rewrites `index.js` in place and clears the *loader* from the require cache, but not the module file it goes on to require — so a second `loadModule('demo', …)` in one test silently re-registers the first source. The new test uses three module ids. This cost a cycle and is worth knowing. ## Not in scope This is only the reconcile path. The lease itself, its deadline, its compare-and-set restore and the participation ledger are the shard's and the module's — `servuo-plugins#22` and `Module-uo#30`. - [x] AI-assisted: Claude Code (Opus 5). Plugin: RunicGateway/servuo-plugins#22 · Sidecar: RunicGateway/link#37 · Module: RunicGateway/Module-uo#30 · Docs: RunicGateway/docs#220 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-05 04:06:28 +00:00
fix(events): give a lease's ledger row a reconcile path (Phase 11b)
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / client-build (pull_request) Successful in 42s
PR Checks / server-tests (pull_request) Failing after 5m48s
809426ad73
A lease row had no reconcile path at all, and nothing failed to say so.
`cleanup.js` resolves a resource to the action of the step that made it, and for
a lease that action is `core.lease` -- a CORE action, on a path a module cannot
register anything on. So every `override` row came back `unanswered` for the life
of the run, and a lease the shard had quietly dropped (a config lease is
memory-only there, so a restart reverts it by design) stayed in the ledger as
live until teardown went hunting a baseline nobody was holding.

`core.lease` gains a `reconcile()`, and `registerEventLeases` gains an optional
`inForce()`: "does the game side still have any record of this hold?"

Deliberately not `read()` plus a comparison. A value that differs from what the
run applied is DRIFT, which teardown must deliver through `restore()` so the row
lands `drifted` with the current value beside it; a reconcile that inferred
absence from a changed value would orphan the row first and tell the operator the
lease vanished rather than that somebody moved it. Only an explicit
`{ ok: true, held: false }` takes a row out -- a throw, a timeout, an
unrecognised shape and a lease with no `inForce()` all leave the ledger alone.

MODULE_API_VERSION stays 1.10.0, amended in place.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit d0178c6419 into edge 2026-09-05 04:10:51 +00:00
whitlocktech deleted branch feature/events-p11b-leases-participation 2026-09-05 04:10:52 +00:00
Sign in to join this conversation.
No description provided.