feat(events): the resource ledger, leases and cleanup (Phase 8)
Some checks failed
PR Checks / client-build (pull_request) Successful in 3m15s
PR Checks / server-tests (pull_request) Failing after 8m21s
PR Checks / bot-tests (pull_request) Successful in 11m12s

Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN.

One table, one core action, one route, one body field, and two members added to
MODULE_API 1.10.0 in place. The safety property the whole world-write half
depends on: core now remembers what a run changed in the world, and gives it
back on every terminal path.

Four decisions settled by the org lead on 2026-09-03, all as recommended:

- A lease is acquired by a new CORE action, `core.lease`. Section F puts the
  duration bound and the two-events-one-target conflict check on core's side of
  the seam, and a lease verb per module would be both re-implemented once per
  module, advisory everywhere.
- Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A
  spawn's ref does not exist until the module answers, so what core writes
  before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never
  comes it stands, and cleanup calls revert() with the key and no resources --
  which is why section F's revert takes the key at all.
- Cleanup is one sweep over the ledger, not synthetic step rows. The
  step-shaped version costs a second retry counter beside `revert_attempts`.
- `reconcile` is declared here and TRIGGERED BY THE MODULE, through
  `ctx.events.reconcile()`. Core has no concept of the game being up, so it
  cannot decide when to ask; it asks once at its own boot.

MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`;
while it is on `edge` it is amended in place, so the whole module contract
reaches an author as one version they read once.

Verify

- `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the
  pre-existing engagementManifest CRLF failure, in a file this branch does not
  touch (`edge` before: 1950/1876/73/1). +75 tests.
- The unique key was proved against a REAL MariaDB, because nothing else can
  prove it: whether multiple NULLs collide in a unique index, whether a STORED
  generated column is recomputed on UPDATE, and whether the SET NULL foreign key
  survives beside it are properties of the server. eventRunnerSql.test.js gained
  16 tests; 65 pass against the container. The real schema.sql was applied to a
  fresh database and to an existing one.
- Client: 362 pass, and it builds. routes:manifest and swagger -- one route
  added, none moved.

The live walk found three defects, and two of them are the phase's real finding

Driven by a throwaway `rig` module in website/modules/, deleted before commit.

1. A lease was never given back at all. `core.lease` reserves its own ledger
   row, so it never went through the ledger's dirty-marking, so a run holding
   only a lease kept `cleanup_status = 'not_required'` and the cleanup leg --
   which selected on `pending` -- never looked at it.
2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing
   sweep moved the run to `incomplete`, which took it out of the leg's own scan
   for ever. The test covering the bound asserted `<= 3` and was satisfied by 1:
   a bound has two halves, and a test that only asserts the ceiling passes
   against a floor.
3. The first fix for (2) made the console lie. Spending every row's
   `revert_attempts` was a tidy way to take a `cleanup: false` run out of a
   counter-bounded scan, and the run page then rendered "3 attempts" beside
   resources nothing had ever tried. Found by opening the page.

Both (1) and (2) are the same mistake: deriving "is there anything to do" from a
summary column instead of from the rows. Neither was visible to a unit test,
because a test that calls the sweep directly never asks what would have selected
the run.

The two properties that need the process to die were walked as the plan asks.
With the module's perform() hanging, the placeholder existed while the dispatch
was in flight and nothing was named; after taskkill and a restart the reclaim
re-dispatched the same idempotency key, the retry re-used its own placeholder,
and everything was given back. Then, with the module reporting one of two
resources as no longer in force, the boot-time reconcile marked the other
`orphaned` -- never `reverted`.

This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as
Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-03 21:19:27 -05:00
parent 57d183e921
commit fdc118166c
29 changed files with 3928 additions and 72 deletions

View File

@@ -513,3 +513,242 @@ test('an action whose module is gone goes dormant, and a step naming it fails te
assert.equal(result.dormant, true)
assert.match(result.error, /no module registers "demo\.summon"/)
})
// ── Phase 8: the ledger's two callables, from a module ─────────────────────
//
// `revert` was already required at registration for `reversible: 'ledger'` —
// Phase 1 put that check in. What Phase 8 added is a caller for it, and
// `reconcile` beside it. Both are proved here through the REAL loader for the
// same reason the four registrations are: a `revert` a test called directly is a
// `revert` core might still have no way to reach.
test('a module\'s revert is reached by the cleanup sweep, resources and key in hand', async () => {
const record = loadModule('demo', `
let seen = null
module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true, resources: [{ kind: 'creature', ref: '0xA' }] } },
async revert(arg) { seen = arg; return { ok: true } },
async reconcile() { return { ok: true, inForce: [] } },
}])
api.registerEventOptionSources([
{ id: 'demo.options.seen', label: 'seen', async resolve() { return [{ value: JSON.stringify(seen), label: 'seen' }] } },
])
}
`)
assertRegistered(record)
const action = registries.eventAction('demo.spawn')
// Both callables survived the registration copy — which is not a given: that
// copy is explicit rather than a spread, precisely so nothing rides along, and
// a member added to the contract without being added to it is a member that
// silently does not exist.
assert.equal(typeof action.revert, 'function')
assert.equal(typeof action.reconcile, 'function')
assert.equal(action.owner, 'demo')
const answer = await action.revert({
runId: 3,
resources: [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }],
idempotencyKey: 'k-1',
})
assert.deepEqual(answer, { ok: true })
// Read back through the module's own option source rather than out of a
// closure this file holds: the point is that what core PASSED is what the
// module SAW, across the seam.
const seen = JSON.parse((await registries.resolveOptionSource('demo.options.seen')).options[0].value)
assert.equal(seen.runId, 3)
assert.equal(seen.idempotencyKey, 'k-1')
assert.deepEqual(seen.resources, [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }])
})
test('reconcile is optional, and a module without one still registers', () => {
// The asymmetry with `revert`, from the loader's side. A module that cannot say
// what the game still has is not broken — core keeps believing its own ledger,
// which is the behaviour before this phase — whereas one that creates something
// and cannot undo it has made a promise core has no way to keep.
const withNone = loadModule('quiet', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'quiet.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true } },
async revert() { return { ok: true } },
}])
}`)
assertRegistered(withNone)
assert.equal(registries.eventAction('quiet.spawn').reconcile, null)
const withoutRevert = loadModule('broken', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'broken.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true } },
}])
}`)
assert.equal(withoutRevert.state, 'startup_failed')
assert.match(withoutRevert.reason, /reversible: 'ledger' but has no revert\(\)/)
})
test('a module cannot claim core\'s reserved resource kind', async () => {
// `@step` is the placeholder's kind, and the placeholder is the row whose
// survival is the safety property: a module able to write one could make its
// own step look already accounted for. Refused at recording, and the STEP still
// succeeds — because it did.
const record = loadModule('sneaky', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'sneaky.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true, resources: [{ kind: '@step', ref: 'anything' }] } },
async revert() { return { ok: true } },
}])
}`)
assertRegistered(record)
// eslint-disable-next-line global-require
const ledger = require('../src/events/ledger')
const result = await dispatch.dispatchStep(step('sneaky.spawn'), { run: RUN })
assert.equal(result.outcome, 'done')
const parsed = ledger.normalise(result.resources[0], 'sneaky.spawn')
assert.equal(parsed.ok, false)
assert.match(parsed.reason, /reserved kind/)
})
test('core registers the lease VERB and a module registers the lease', async () => {
// The seam working the way round it is meant to (Phase 8). A module ships the
// three callables; the verb an author puts in a step is `core.lease`, so the
// duration bound and the two-events-one-target conflict check live in one place
// rather than being re-implemented once per module and advisory everywhere.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.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 } },
}])
}`)
assertRegistered(record)
registries.registerCore()
// The module registers no ACTION at all, and its lease is still reachable.
assert.equal(registries.eventAction('demo.lease'), null)
assert.equal(registries.eventAction('core.lease').reversible, 'override')
// And the dropdown behind `core.lease`'s first param is answered by what the
// module declared — resolved per request, so a module that booted later is
// still in the list.
const options = await registries.resolveOptionSource('core.options.leases')
assert.equal(options.ok, true)
assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }])
})
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
// too long will still be too long in sixty seconds: it is an authoring error,
// not an outage.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.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 } },
}])
}`)
assertRegistered(record)
registries.registerCore()
const tooLong = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 120 }),
{ run: RUN },
)
assert.equal(tooLong.outcome, 'terminal')
assert.match(tooLong.error, /at most 60 minutes, not 120/)
// The same for a value outside the declared range. Unlike a cap, a bad lease
// value is in force the moment it is applied, which is why min/max are required
// on the numeric types rather than advisory.
const tooBig = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '9', minutes: 10 }),
{ run: RUN },
)
assert.equal(tooBig.outcome, 'terminal')
assert.match(tooBig.error, /accepts 0\.5 to 5/)
// And a lease nobody registers, which is the dormancy rule one registry along.
const missing = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.nope', value: '3', minutes: 10 }),
{ run: RUN },
)
assert.equal(missing.outcome, 'terminal')
assert.match(missing.error, /no module registers the lease "demo\.nope"/)
})
test('a dry run of core.lease checks everything and takes nothing', async () => {
// `verify: true` must change nothing and must answer honestly (§F). A verify
// that reserved the target would be a dry run that changed something — and it
// would then refuse the real run that followed it, which is the worst of both.
let applied = 0
const record = loadModule('demo', `
let applied = 0
module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { applied += 1; return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([
{ id: 'demo.options.applied', label: 'applied', async resolve() { return [{ value: String(applied), label: 'n' }] } },
])
}
`)
assertRegistered(record)
registries.registerCore()
void applied
const ok = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10 }),
{ run: RUN, verify: true },
)
assert.equal(ok.outcome, 'done')
assert.equal((await registries.resolveOptionSource('demo.options.applied')).options[0].value, '0')
// A dry run that is still a real check: the bad duration is caught with
// `verify: true` as well, which is the whole value of the switchboard's
// "find out before you schedule it".
const bad = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 999 }),
{ run: RUN, verify: true },
)
assert.equal(bad.outcome, 'terminal')
})