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

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>
This commit is contained in:
2026-09-04 19:31:44 -05:00
parent aba8d1e43a
commit 809426ad73
4 changed files with 187 additions and 1 deletions

View File

@@ -422,6 +422,58 @@ const ACTIONS = [
await require('../events/ledger').markRunDirty(runId)
return { ok: true }
},
/**
* Which of this run's leases the game side still has a record of (Phase 11b).
*
* **A lease row had no reconcile path at all until this existed**, 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 restart reverts every config lease, by design) stayed in the
* ledger as live until teardown went looking for a baseline nobody was holding.
*
* The question asked is deliberately NOT "is the value still what we applied".
* That is drift, and drift is teardown's verdict to deliver through `restore`
* so the row lands as `drifted` with the current value beside it. A reconcile
* that inferred absence from a changed value would orphan the row first and
* throw that away — the operator would be told the lease vanished rather than
* that somebody moved it.
*
* A lease with no `inForce()` is reported in force, which is core's posture
* everywhere else in this file: "I could not ask" must never be recorded as
* "it is gone".
*/
async reconcile({ resources }) {
const inForce = []
for (const row of resources || []) {
if (row.kind !== 'override') continue
const lease = registries.eventLease(row.ref)
if (!lease || typeof lease.inForce !== 'function') {
inForce.push(row.ref)
continue
}
let answer
try {
answer = await lease.inForce({ ref: row.ref, payload: row.payload || null })
} catch (err) {
answer = null
}
// Only an explicit `held: false` takes a row out. A module that threw, timed
// out, or answered something unrecognisable has not said the lease is gone.
if (answer && answer.ok === true && answer.held === false) continue
inForce.push(row.ref)
}
return { ok: true, inForce }
},
},
{

View File

@@ -483,7 +483,7 @@ const isEventBudget = (id) => eventBudgets.has(id)
* one by id.
*/
const allEventLeases = () =>
[...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest)
[...eventLeases.values()].map(({ read, apply: applyValue, restore, inForce, ...rest }) => rest)
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
const eventLease = (id) => eventLeases.get(id) || null
@@ -1192,6 +1192,26 @@ function checkEventLeaseShape(entry) {
if (typeof l[fn] !== 'function') throw new Error(`registerEventLeases: ${l.id} has no ${fn}()`)
}
// **`inForce()` is optional, and it is the fourth question a lease can answer**
// (Phase 11b). `read` is "what is it now", `apply` is "hold it here", `restore`
// is "put it back and tell me if somebody moved it" — and none of the three
// answers "does the game side still have any record of this hold?", which is
// what a reconcile after an outage needs.
//
// It is deliberately not `read()` with a comparison. A value that differs from
// what the event applied is DRIFT, and drift is a verdict teardown has to
// deliver through `restore` so the resource lands as `drifted`; a reconcile
// that inferred absence from a changed value would orphan the row first and
// destroy the one signal an operator needs. The two questions have different
// answers on purpose.
//
// Optional because the fallback is the posture core takes everywhere else: a
// lease that cannot say leaves its ledger row alone, which is exactly the
// behaviour before this phase.
if (l.inForce !== undefined && typeof l.inForce !== 'function') {
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
}
return {
id: l.id,
label: l.label,
@@ -1203,6 +1223,7 @@ function checkEventLeaseShape(entry) {
read: l.read,
apply: l.apply,
restore: l.restore,
inForce: l.inForce || null,
}
}