feat(events): the resource ledger, leases and cleanup (Phase 8)
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:
@@ -44,10 +44,15 @@ const register = (owner, entries) => {
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
test('core registers its three actions on every boot', () => {
|
||||
test('core registers its four actions on every boot', () => {
|
||||
registries.registerCore()
|
||||
const ids = registries.allEventActions().map((a) => a.id)
|
||||
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
|
||||
// `core.lease` joined the three in Phase 8, and it is the only one of the four
|
||||
// that genuinely changes the world — which is why it is core's rather than each
|
||||
// module's: §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 that
|
||||
// bound re-implemented once per module and advisory everywhere.
|
||||
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue', 'core.lease'])
|
||||
assert.equal(ids.length, coreEventActions.ACTIONS.length)
|
||||
})
|
||||
|
||||
@@ -56,6 +61,7 @@ test('the catalog carries no callable', () => {
|
||||
for (const action of registries.allEventActions()) {
|
||||
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
|
||||
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
|
||||
assert.equal(action.reconcile, undefined, `${action.id} leaked reconcile`)
|
||||
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
|
||||
}
|
||||
// …and the runner's own lookup still has it, which is the half that makes the
|
||||
@@ -242,7 +248,7 @@ test('a whole batch is refused or taken, never half', () => {
|
||||
|
||||
test('_reset() hands the process back', () => {
|
||||
registries.registerCore()
|
||||
assert.equal(registries.allEventActions().length, 3)
|
||||
assert.equal(registries.allEventActions().length, 4)
|
||||
registries._reset()
|
||||
assert.equal(registries.allEventActions().length, 0)
|
||||
assert.equal(registries.isEventAction('core.wait'), false)
|
||||
@@ -366,9 +372,80 @@ test('an option source needs a resolver, and core registers one of its own', ()
|
||||
// Core through the same door (Phase 7): `core.announce`'s `leg` param names a
|
||||
// source, and the announce legs are already a registry with labels in them.
|
||||
registries.registerCore()
|
||||
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['core.options.legs'])
|
||||
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), [
|
||||
'core.options.legs',
|
||||
// Phase 8's, and it is the same argument one phase on: `core.lease`'s `lease`
|
||||
// param would otherwise be a free-text box whose typo is caught at dispatch,
|
||||
// mid-run — and the leases are already a registry with labels in them.
|
||||
'core.options.leases',
|
||||
])
|
||||
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
|
||||
assert.equal(leg.source, 'core.options.legs')
|
||||
const which = registries.eventAction('core.lease').params.find((p) => p.name === 'lease')
|
||||
assert.equal(which.source, 'core.options.leases')
|
||||
})
|
||||
|
||||
// ── `reconcile`, the one member Phase 8 added to the action shape ──────────
|
||||
//
|
||||
// Optional where `revert` is required, and the asymmetry is the design: 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 a module
|
||||
// that created something and cannot undo it has made a promise core has no way
|
||||
// to keep.
|
||||
|
||||
test('reconcile is optional, must be a function, and only on an action that ledgers', () => {
|
||||
const ledgering = {
|
||||
id: 'demo.spawn',
|
||||
label: 'Spawn',
|
||||
risk: 'change',
|
||||
reversible: 'ledger',
|
||||
perform: async () => ({ ok: true }),
|
||||
revert: async () => ({ ok: true }),
|
||||
}
|
||||
|
||||
// Absent is legal, and it lands as an explicit null rather than as a missing
|
||||
// key — the same shape `revert` and `cost` take, so the catalog's strip list
|
||||
// and the sweep's `typeof` check both have something to look at.
|
||||
register('demo', [ledgering])
|
||||
assert.equal(registries.eventAction('demo.spawn').reconcile, null)
|
||||
registries._reset()
|
||||
|
||||
assert.throws(
|
||||
() => register('demo', [{ ...ledgering, reconcile: 'yes please' }]),
|
||||
/reconcile must be a function/,
|
||||
)
|
||||
|
||||
// The mirror check `revert` already has. An action that ledgers nothing has no
|
||||
// rows for core to ask about, so a `reconcile` on one is an author who believes
|
||||
// something is being tracked and a sweep that will never call it.
|
||||
assert.throws(
|
||||
() =>
|
||||
register('demo', [
|
||||
{
|
||||
id: 'demo.shout',
|
||||
label: 'Shout',
|
||||
risk: 'notify',
|
||||
reversible: 'none',
|
||||
perform: async () => ({ ok: true }),
|
||||
reconcile: async () => ({ ok: true, inForce: [] }),
|
||||
},
|
||||
]),
|
||||
/declares reconcile\(\) but is reversible: 'none' and ledgers nothing/,
|
||||
)
|
||||
})
|
||||
|
||||
test('an override action may reconcile, because a lease is ledgered too', () => {
|
||||
register('demo', [
|
||||
{
|
||||
id: 'demo.borrow',
|
||||
label: 'Borrow',
|
||||
risk: 'change',
|
||||
reversible: 'override',
|
||||
perform: async () => ({ ok: true }),
|
||||
reconcile: async () => ({ ok: true, inForce: [] }),
|
||||
},
|
||||
])
|
||||
assert.equal(typeof registries.eventAction('demo.borrow').reconcile, 'function')
|
||||
})
|
||||
|
||||
test('_reset() hands back the three new registries too', () => {
|
||||
|
||||
Reference in New Issue
Block a user