docs(events): Phase 8 as built -- the resource ledger, leases and cleanup

The docs half of Event System Phase 8. Code: RunicGateway/website#NNN.

EVENTS.md

Section D's ledger row is corrected in two places and section L gains a new
subsection recording what the build settled:

- Rule 1 needed a mechanism, because a spawn's ref does not exist until the
  module answers. What goes in before the dispatch is a PLACEHOLDER keyed by the
  step's idempotency key, and that is why revert() takes the key at all.
- A LEASE does not use it and gets rule 1 in a stronger form: its target is the
  lease id the step already names, so `core.lease` writes the real row first --
  the only moment the two-events-one-target refusal can happen before the world
  has been written to.
- The unique key is held by three statuses and released by three (amended
  2026-09-03). "Among non-reverted rows" was written before the six statuses had
  their meanings; taken literally it makes `drifted` and `orphaned` hold a target
  for ever, so one bad night would disable a lease permanently with no control
  able to clear it.
- MariaDB has no partial index, so the encoding is a STORED generated column
  reading `status` ALONE -- TEAMS.md 2.5's correction, because MariaDB refuses
  ON DELETE SET NULL on a foreign key whose column is a base column of one.
- Cleanup is one sweep, not synthetic step rows, and it runs from one place.
- What that sweep SELECTS cost two live-walk defects in opposite directions, and
  the pair is the finding worth keeping: deriving "is there anything to do" from
  a summary column instead of from the rows stranded a lease outright, and then
  made the retry bound mean one attempt. Both are recorded with the reasoning.

Section F gains the lease's verb and the reconcile bullet; section I's "cancel
takes { reason } and not { cleanup }" becomes what shipped; section K's role
table puts re-running cleanup in the admin-only row; section L's cancel and
cleanup-fails rows are updated; Observability names the six new log kinds; and
the API surface table's absent-routes list is now empty.

EVENTS_PLAN.md

Phase 8 marked complete in the shape Phases 0-7 use: the four org-lead
decisions, the four things the build settled, the verified numbers, and the live
walk -- including the three defects only it could find and the two properties
that needed the process to die.

MODULE_API.md

1.10.0 gains `reconcile()` and `ctx.events.reconcile()` IN PLACE rather than a
new version. A protocol owes a bump once it has landed on `main`; while it is on
`edge` it is amended in place, which is the rule the Teams workstream arrived
at, applied to a module API for the first time. The rule list grows from six to
ten: the lease's verb is core's, `until` goes down the wire, revert is
idempotent and reverting something that does not exist is a success, revert is
also called with the key and an empty list, and reconcile is optional where
revert is required.

Checked by hand

`docs` has no CI, so: every anchor in EVENTS.md resolves (checked by generating
the heading slugs and diffing), and every route, identifier, env var and file
path named here exists on the website branch -- `core.lease`,
`core.options.leases`, `ctx.events.reconcile`, `EVENT_REVERT_MAX_ATTEMPTS`,
`event_run_resources`, `live_marker`, `uq_evres_target`, the six log kinds, and
the route in both routes.manifest.json and the OpenAPI spec. Diffs are 144/21,
139/16 and 67/9 with NO CRLF artefact, verified by comparing --numstat against
the real content diff rather than trusting it.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-03 21:20:04 -05:00
parent 3f5aa3b074
commit 07512d846d
3 changed files with 350 additions and 46 deletions

View File

@@ -35,12 +35,19 @@ module chunk evaluates, which is earlier than any network round trip could answe
**1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phase 7). Four
additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8).
Four additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
deployment simply has fewer verbs an event can use — which is §F's own posture stated as a version
rule, because core with none of this installed is still an event engine that can announce, wait, cue
a human and publish results.
> **Phase 8 added `reconcile()` and `ctx.events.reconcile()` to this same version rather than to a
> new one** (org lead, 2026-09-03). A protocol owes a bump once it has landed on `main`; while it is
> on `edge` it is amended in place — the rule the Teams workstream arrived at, applied to a module
> API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as
> one version they read once, which was the argument for putting the lease declaration here in the
> first place.
**Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`,
`core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in —
`loader.js` built its own `api` facade and had no method that delegated to it. So the seam a module
@@ -70,15 +77,31 @@ api.registerEventActions([{
if (verify) return { ok: true } // dry run: validate, change NOTHING
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
},
async revert({ runId, resources, idempotencyKey }) { return { ok: true } }, // iff reversible: 'ledger'
// Required iff reversible: 'ledger'. Called by core's cleanup sweep at teardown,
// over the rows this action's `resources` produced — a LIST, so twelve creatures
// are one round trip. `{ ok: true }` reverts the group; `failed: ['0x...']` names
// the ones that did not come back.
async revert({ runId, resources, idempotencyKey }) { return { ok: true } },
// OPTIONAL, and only on an action that ledgers. "Which of these does the game
// still have?" — asked after something outside core restarted.
async reconcile({ runId, resources }) { return { ok: true, inForce: ['0x40001234'] } },
}])
// The module says WHEN, because core cannot: core has no concept of the game
// being up. module-uo already watches `bootId` to tell a shard restart from a
// sidecar reconnect, and that is the moment a ledger of live spawns has become a
// claim about a world that no longer exists.
ctx.events.reconcile()
api.registerEventOptionSources([{
id: 'uo.options.creatures', label: 'Creatures',
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
}])
api.registerEventLeases([{ // DECLARED here; acquired by nothing yet
// A value a run may borrow. The module ships the three callables; the VERB an
// author puts in a step is core's `core.lease`, so the duration bound and the
// two-events-one-target conflict check live in one place.
api.registerEventLeases([{
id: 'uo.rate.skillgain', label: 'Skill gain rate',
type: 'float', min: 0.5, max: 5, maxDurationMs: 86400000,
async read() { return { ok: true, value: 1.0 } },
@@ -109,9 +132,31 @@ field:
`<moduleId>.`. An action names a VERB, a budget a RESOURCE, a lease a VALUE and an option source a
CATALOG, so `uo.creatures` may legitimately appear in more than one — reading that as a collision
would forbid the most natural set of names a module will ever write.
- **A lease is DECLARED at 1.10.0 and acquired by nothing.** Core owns a lease's duration and its
conflict check, and both live in the resource ledger, which is `EVENTS_PLAN.md` Phase 8. It is in
this version rather than the next so the module contract is one version an author reads once.
- **A lease is declared by a module and acquired by CORE.** The verb is `core.lease`, and the
module never writes one: core reads the baseline, reserves the target in the resource ledger —
which is where "two events cannot hold one target" comes from, as a unique index rather than as a
check — applies the value with the deadline, and restores it at teardown through the module's own
`restore()`. A lease verb per module would be that bound re-implemented once per module, advisory
everywhere, and wrong in the first one that forgot it.
- **`until` goes down the wire, and the game side must honour it without being asked again.** A
module that treats it as advisory has produced a lease that outlives an outage, which is the one
thing a lease exists to prevent. Core's copy of the deadline is for the console; the game's copy is
the fail-safe.
- **`revert` must be idempotent, and reverting something that does not exist is a SUCCESS.** Core
records a resource BEFORE it is confirmed (`EVENTS.md` §D rule 1), so a dispatch whose answer was
lost leaves a row for something that may never have existed — and cleanup will ask about it. A
module never has to tell "I deleted it" from "it was not there". This is also what a Rust-style
monthly wipe needs, and the second reason a lease's restore must be idempotent too.
- **`revert` is also called with NO resources and only an idempotency key.** That is the lost-answer
case: core knows a dispatch went out under that key and never learned what it made. A module that
can undo by key answers honestly; one that cannot answers `{ ok: false }` and the row stays visible
to an operator, which is the correct outcome rather than a silent one.
- **`reconcile` is 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 version — whereas one that created something and cannot undo it has
made a promise core has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }`
leaves the ledger alone: **"I do not know" is never read as "it is gone"**, and a resource a module
reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go.
**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
@@ -576,7 +621,7 @@ api.registerSlashCommands([{ name, description, options, access, handler }]) //
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert }]) // 1.10.0
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
api.registerEventBudgets([{ id, label, unit }]) // 1.10.0
api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }]) // 1.10.0
api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0
@@ -954,7 +999,7 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio
**`registerEventActions([...])` / `registerEventBudgets([...])` / `registerEventLeases([...])` /
`registerEventOptionSources([...])`** (1.10.0) are the event contract (`EVENTS.md` §F). The full
shapes and the six rules that come with them are in §1.1 under **1.10.0**; four things are contract
shapes and the ten rules that come with them are in §1.1 under **1.10.0**; six things are contract
rather than implementation and belong here:
- **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s
@@ -975,6 +1020,19 @@ rather than implementation and belong here:
the authoring form and it never raises. The alternative is a screen a module's outage can take
away, for a field whose value the operator very often already knows — which is a worse failure than
the typo the dropdown exists to prevent.
- **Core records what an action made BEFORE the action is dispatched, not after** (`EVENTS.md` §D
rule 1). A module's `resources` are the refs core did not know until the answer arrived; what core
wrote beforehand is a placeholder keyed by the step's idempotency key, so a dispatch whose answer
never came back is still something cleanup can act on. The consequence for a module author is the
whole reason `revert` takes `idempotencyKey` as well as `resources`: it will sometimes be called
with the key and an EMPTY list, meaning *"a command went out under this key and core never learned
what it did"*. Answering that honestly is what makes an unattended world write recoverable; a
module that cannot answer it says so, and the row stays visible to an operator.
- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an
action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it
runs on every terminal path — completion, cancellation and abort alike — so a module's only job is
to answer `revert` correctly however many times it is asked.
**An action whose module is uninstalled goes dormant, never an error.** A step already in a saved
spec keeps it and a new step may not add one — the shape `engagement_rules` established for a dormant