diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js index b972530..468e704 100644 --- a/server/src/config/coreEventActions.js +++ b/server/src/config/coreEventActions.js @@ -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 } + }, }, { diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 88d52e8..db2539b 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -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, } } diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js index f70bc56..9b3b5ca 100644 --- a/server/test/eventCleanup.test.js +++ b/server/test/eventCleanup.test.js @@ -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') diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index c9d6520..9efd157 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -181,6 +181,7 @@ test('the catalog never carries a callable, whichever registration it came from' assert.equal(l.read, undefined) assert.equal(l.apply, undefined) assert.equal(l.restore, undefined) + assert.equal(l.inForce, undefined) } for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined) }) @@ -661,6 +662,48 @@ test('core registers the lease VERB and a module registers the lease', async () assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }]) }) +test('inForce() is optional, and a fourth question rather than a fourth spelling of read()', () => { + // 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 them answers + // "does the game side still have any record of this hold?", which is what a + // reconcile after an outage needs. A config lease is reverted by a shard restart + // by design, so the answer changes without the value ever being written by us. + // + // Optional, because the fallback is core's posture everywhere: a lease that + // cannot say leaves its ledger row alone. + // + // Three module IDs rather than three reloads of one: `loadModule` rewrites + // `index.js` in place and clears the LOADER from the require cache, but not the + // module file it goes on to require. A second load of the same id silently + // re-registers the first source. + const lease = (id, extra) => `module.exports = (ctx, api) => { + api.registerEventLeases([{ + id: '${id}.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5, + maxDurationMs: 3600000, + async read() { return { ok: true, value: 1 } }, + async apply() { return { ok: true } }, + async restore() { return { ok: true } }, + ${extra} + }]) + }` + + const without = loadModule('leasea', lease('leasea', '')) + assertRegistered(without) + assert.equal(registries.eventLease('leasea.rate.gain').inForce, null) + + const withIt = loadModule('leaseb', lease('leaseb', 'async inForce() { return { ok: true, held: true } },')) + assertRegistered(withIt) + assert.equal(typeof registries.eventLease('leaseb.rate.gain').inForce, 'function') + + // And a declaration that is present but not callable fails the module rather + // than being ignored -- the same posture the other three take. A module that + // meant to answer and cannot is a module whose leases would silently never be + // reconciled. + const broken = loadModule('leasec', lease('leasec', "inForce: 'yes',")) + assert.equal(broken.state, 'startup_failed') + assert.match(broken.reason, /inForce must be a function/) +}) + test('core refuses a lease held longer than the module allows', async () => { // The bound is the MODULE's number and the enforcement is CORE's, which is the // §F split stated as one assertion. `retry: false` because a duration that is