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

@@ -228,6 +228,39 @@ function buildCtx(id, moduleRoot) {
emit: (triggerId, envelope) => {
engagementEmit.emit(id, triggerId, envelope)
},
// EVENTS.md §L, and the resource ledger (Phase 8). "On reconnect the runner
// asks each ledgered resource's module to reconcile" — and this is how the
// runner learns there has BEEN a reconnect.
//
// **Core cannot decide when to call this, and that is the contract rather
// than a gap.** §F: core has no concept of the game being up, because a
// module with six sidecars cannot answer that question in the singular. So
// the module says so, when it sees its own — module-uo already watches
// `bootId` to tell a shard restart from a sidecar reconnect, which is
// exactly the moment a ledger of live spawns has become a claim about a
// world that no longer exists.
//
// `id` is bound here and never taken from the arguments, like `emit` and
// `teams.activity.push` before it: a module reconciles its OWN ledger, and
// without the binding this would be a way to have core mark another
// module's resources orphaned.
//
// Fire-and-forget and returns undefined, for the third time and the same
// reason: this is called from inside a connection handler, and there is
// nothing a module could correctly do with a failure of core's bookkeeping.
reconcile: () => {
// eslint-disable-next-line global-require
require('../events/cleanup')
.reconcileModule(id)
.then(
(summary) => {
if (summary && summary.orphaned) {
log.warn('event resources orphaned on reconcile', { module: id, ...summary })
}
},
(err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
)
},
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. Live from Phase 7; it threw until the `user_notifications` table

View File

@@ -157,11 +157,11 @@ const eventBudgets = new Map()
// lease id → { owner, id, label, type, min, max, maxDurationMs, description,
// read, apply, restore } (§F "Leases: one more declaration", Phase 7).
//
// **Phase 7 registers a lease and nothing acquires one.** Core owns the duration
// and the conflict check, the module owns reading the current value and writing a
// new one — and both halves of that live in the resource ledger, which is Phase
// 8's. What is here is the declaration, its validation and its catalog entry, so
// that the module contract is one version rather than two.
// **Core owns the duration and the conflict check; the module owns reading the
// current value and writing a new one.** Phase 7 registered a lease and nothing
// acquired one; Phase 8 gave it a verb — `core.lease`, a CORE action, so the
// bound and the two-events-one-target refusal are enforced in one place rather
// than re-implemented by every module that ships a lease.
const eventLeases = new Map()
// source id → { owner, id, label, description, resolve } (§F "Param option
@@ -435,14 +435,14 @@ async function resolveAudience(id, params = {}) {
/**
* Every declaration WITHOUT its callables — what the admin catalog serves.
*
* `perform`, `revert` and `cost` are stripped for the same reason `resolve` is
* `perform`, `revert`, `reconcile` and `cost` are stripped for the same reason `resolve` is
* stripped from an audience and `handler` from a slash command: this is the
* object that leaves the process, and the browser's whole relationship with an
* action is naming one by id. §F's "a module registers actions server-side and
* adds no routes for them" is only true if the functions never ride out.
*/
const allEventActions = () =>
[...eventActions.values()].map(({ perform, revert, cost, ...rest }) => rest)
[...eventActions.values()].map(({ perform, revert, reconcile, cost, ...rest }) => rest)
/** One declaration, callables included. The runner's lookup (Phase 2). */
const eventAction = (id) => eventActions.get(id) || null
@@ -485,7 +485,7 @@ const isEventBudget = (id) => eventBudgets.has(id)
const allEventLeases = () =>
[...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest)
/** One lease, callables included. Phase 8's lookup; nothing calls it yet. */
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
const eventLease = (id) => eventLeases.get(id) || null
/** Every option source WITHOUT its resolver — the authoring form's list. */
@@ -985,7 +985,7 @@ function checkActionParam(actionId, entry, seen) {
}
/**
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`.
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile }])`.
*
* A typed verb core may ask a registrant to carry out. Everything decidable from
* the argument alone is decided here, at the call; the collision — is this id
@@ -1042,6 +1042,23 @@ function checkEventActionShape(entry) {
`registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
)
}
// §L's reconnect row, and it is OPTIONAL where `revert` is required (Phase 8).
// `revert` is how a run gives a resource back; `reconcile` is how a module says
// which of them the game still has after something outside core restarted. A
// module that cannot answer that question is not broken -- core simply keeps
// believing its own ledger, which is the pre-Phase-8 behaviour -- whereas a
// module that created something and cannot undo it has made a promise core has
// no way to keep. Only meaningful for an action that ledgers anything.
if (a.reconcile !== undefined) {
if (typeof a.reconcile !== 'function') {
throw new Error(`registerEventActions: ${a.id} reconcile must be a function`)
}
if (a.reversible === 'none' || a.reversible === 'self') {
throw new Error(
`registerEventActions: ${a.id} declares reconcile() but is reversible: '${a.reversible}' and ledgers nothing`,
)
}
}
if (a.cost !== undefined && typeof a.cost !== 'function') {
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
}
@@ -1076,6 +1093,7 @@ function checkEventActionShape(entry) {
cost: a.cost || null,
perform: a.perform,
revert: a.revert || null,
reconcile: a.reconcile || null,
}
}
@@ -1130,10 +1148,12 @@ function checkEventBudgetShape(entry) {
* thing a module must not be allowed to skip. A lease whose restore writes blindly
* is a lease that silently reverts an operator's manual fix.
*
* **Nothing acquires a lease in Phase 7.** This registers, validates and serves
* one; the ledger that holds it, the deadline that goes down the wire and the
* drift answer are Phase 8's. Declaring it now is what keeps the module contract
* one version rather than two.
* **A lease is acquired by `core.lease` and by nothing else** (Phase 8). The step
* names a lease id, a value and a duration; core reads the baseline, reserves the
* target in `event_run_resources` — which is where the two-events-one-target
* refusal comes from — applies the value with the deadline, and restores it at
* teardown through the same `restore()` the drift check lives in. A module ships
* the three callables and never has to own any of that.
*/
function checkEventLeaseShape(entry) {
const l = entry || {}