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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user