fix(events): give a lease's ledger row a reconcile path (Phase 11b)
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:
@@ -485,6 +485,76 @@ test('placeholders are not asked about, because there is nothing to ask yet', as
|
||||
assert.equal([...store.rows.values()][0].status, 'pending')
|
||||
})
|
||||
|
||||
// ── Reconciling a LEASE (Phase 11b) ────────────────────────────────────────
|
||||
|
||||
/// A lease row's action is `core.lease`, so these need core's own registrations.
|
||||
function registerCoreAnd(leaseOver = {}) {
|
||||
registries.registerCore()
|
||||
registerLease(leaseOver)
|
||||
}
|
||||
|
||||
test('a lease row had no reconcile path at all until core.lease grew one', async () => {
|
||||
// The hole this phase closed, asserted from the outside. `reconcileModule`
|
||||
// resolves a resource to the action of the step that made it, and for a lease
|
||||
// that action is CORE's — a path no module can register anything on. So every
|
||||
// `override` row came back `unanswered` for the life of the run, and a lease the
|
||||
// shard had quietly dropped stayed in the ledger as live until teardown went
|
||||
// looking for a baseline nobody was holding.
|
||||
registerCoreAnd({ inForce: async () => ({ ok: true, held: false }) })
|
||||
addStep(1, 'core.lease')
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'orphaned')
|
||||
})
|
||||
|
||||
test('a lease the shard still has a record of stays put', async () => {
|
||||
registerCoreAnd({ inForce: async () => ({ ok: true, held: true }) })
|
||||
addStep(1, 'core.lease')
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
test('a lease that cannot say is left alone, and DRIFT is not what this asks about', async () => {
|
||||
// Two properties in one walk. Every unanswerable shape leaves the row exactly as
|
||||
// it was, which is core's posture everywhere else — and a lease with no
|
||||
// `inForce()` at all is one of those shapes rather than a boot failure.
|
||||
//
|
||||
// The second is the reason `inForce` exists instead of a comparison against
|
||||
// `read()`: a value that differs from what the event applied is drift, and drift
|
||||
// is teardown's verdict to 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 throw that away — telling the
|
||||
// operator the lease vanished rather than that somebody moved it.
|
||||
const shrugs = [
|
||||
undefined, // no inForce() declared at all
|
||||
async () => null,
|
||||
async () => ({ ok: false, held: false }), // could not ask; not an answer
|
||||
async () => ({ ok: true }), // answered without saying
|
||||
async () => { throw new Error('sidecar gone') },
|
||||
// The drift shape. `read()` would report 4.5 against an applied 3, and this
|
||||
// must NOT be read as "the lease is gone".
|
||||
async () => ({ ok: true, held: true, current: 4.5 }),
|
||||
]
|
||||
|
||||
for (const inForce of shrugs) {
|
||||
store.rows.clear()
|
||||
store.log.length = 0
|
||||
registries._reset()
|
||||
registerCoreAnd(inForce === undefined ? {} : { inForce })
|
||||
addStep(1, 'core.lease')
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.equal(summary.orphaned, 0, String(inForce))
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed', String(inForce))
|
||||
}
|
||||
})
|
||||
|
||||
test('reconcileAll asks every module that owns a live row', async () => {
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
|
||||
Reference in New Issue
Block a user