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>
452 lines
20 KiB
JavaScript
452 lines
20 KiB
JavaScript
// ── Core's own event actions ───────────────────────────────────────────────
|
|
//
|
|
// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
|
|
// and registered through the same staging area a module will use in Phase 7 —
|
|
// which is the entire reason these three exist this early. A registry whose first
|
|
// real registrant is a module is a registry that has already drifted, and §F's
|
|
// claim that core is "an event engine that can announce, wait, cue a human and
|
|
// publish results" with NO module installed is only true if core declares the
|
|
// verbs that do it.
|
|
//
|
|
// **Three actions, and between them they cover the three things an event can do
|
|
// that name no game noun at all**: tell people something, let time pass, and ask
|
|
// a human to go and do something. A deployment with no game module installed has
|
|
// a working event system made of exactly these.
|
|
//
|
|
// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
|
|
// names no game noun either — it borrows a value some module declared — but
|
|
// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
|
|
// and therefore default-off, admin-only and cap-checked like any module verb.
|
|
// It is CORE's rather than each module's because §F puts the duration bound and
|
|
// the two-events-one-target conflict check on core's side of the seam: 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.
|
|
//
|
|
// **Phase 2 gave all three real bodies**, and between them they exercise every
|
|
// shape §F's envelope can take: `core.announce` does work and finishes,
|
|
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
|
|
// without finishing at all. The runner learns nothing about any of them by id —
|
|
// each says what it needs in the envelope, through the same two members Phase 7
|
|
// hands to a module.
|
|
//
|
|
// **This file must not touch the database.** It is required from `registerCore()`,
|
|
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
|
|
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
|
|
// looked up inside `perform()`, per call, which is also what makes a leg
|
|
// registered by a module that booted later reachable at all. `core.lease` is the
|
|
// one action here that reaches a table, and it requires the model INSIDE
|
|
// `perform()` for the same reason — a top-level require would make this file
|
|
// build a pool during route-manifest generation.
|
|
|
|
const registries = require('../modules/registries')
|
|
|
|
|
|
/**
|
|
* Turn the `value` param's text into whatever the named lease says it holds.
|
|
*
|
|
* The range check is here too, and it is REQUIRED on the numeric types for the
|
|
* reason §F gives: unlike a cap, a bad lease value is in force the moment it is
|
|
* applied, so "0.5 to 5" is not advice.
|
|
*/
|
|
function coerceLeaseValue(lease, raw) {
|
|
const text = String(raw === undefined || raw === null ? '' : raw).trim()
|
|
if (lease.type === 'string') return { ok: true, value: text }
|
|
if (lease.type === 'bool') {
|
|
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
|
|
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
|
|
return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
|
|
}
|
|
const n = Number(text)
|
|
if (text === '' || !Number.isFinite(n)) {
|
|
return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
|
|
}
|
|
if (lease.type === 'int' && !Number.isInteger(n)) {
|
|
return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
|
|
}
|
|
if (n < lease.min || n > lease.max) {
|
|
return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
|
|
}
|
|
return { ok: true, value: n }
|
|
}
|
|
|
|
const ACTIONS = [
|
|
{
|
|
id: 'core.announce',
|
|
label: 'Announce',
|
|
description:
|
|
'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
|
|
|
|
// Nothing in the world changes and nothing is created: a message goes out.
|
|
// That is what makes the default `on_failure` for this step `retry -> skip`
|
|
// (§L) rather than `pause`, and it is the honest class even though the
|
|
// message itself cannot be unsent.
|
|
risk: 'notify',
|
|
// A sent announcement is gone. `none` rather than `ledger` is not an
|
|
// omission — there is no undo to write, and declaring `ledger` would put a
|
|
// row in the cleanup ledger that teardown could never resolve.
|
|
reversible: 'none',
|
|
version: 1,
|
|
|
|
params: [
|
|
{
|
|
// A leg id, checked against the announce-leg registry at dispatch rather
|
|
// than here: legs are registered by modules, and this file is evaluated
|
|
// before any module has registered anything.
|
|
//
|
|
// **`source` is what moves that check earlier** (Phase 7). The dispatch
|
|
// check stays — a module can boot between authoring and the run — but
|
|
// until now a typo here was caught mid-run and nowhere else, which is the
|
|
// defect Phase 6's walk hit: an announce leg "site" no module registers,
|
|
// found by a dry run rather than by the form that accepted it.
|
|
name: 'leg',
|
|
type: 'string',
|
|
required: true,
|
|
example: 'discord',
|
|
source: 'core.options.legs',
|
|
description: 'The announce leg to publish on. Registered legs only.',
|
|
},
|
|
{
|
|
name: 'title',
|
|
type: 'string',
|
|
required: false,
|
|
example: 'The gates of Britain open at dusk',
|
|
description: 'Optional heading, for legs that render one.',
|
|
},
|
|
{
|
|
name: 'body',
|
|
type: 'string',
|
|
required: true,
|
|
example: 'A caravan has been sighted on the road east of Cove.',
|
|
description: 'The announcement itself. Plain text.',
|
|
},
|
|
],
|
|
|
|
/**
|
|
* Publish through the announce leg the step names.
|
|
*
|
|
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
|
|
* `discord` is core's and `towncrier` is module-uo's, both already registered,
|
|
* both already carrying a `classify()` that knows what their transport's
|
|
* failures mean. An event announcement that went out by some other path would
|
|
* be a second delivery mechanism with its own bugs.
|
|
*
|
|
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
|
|
* — so an event announcement is presented as one. `excerpt` is the body
|
|
* because it is the field every leg renders as prose, and `image_url` is null
|
|
* because an event announcement has no article behind it to illustrate.
|
|
* Widening the leg contract to carry a second payload shape is a
|
|
* MODULE_API change, and Phase 7 is where those are made.
|
|
*
|
|
* The leg id is checked HERE rather than at authoring time, and that is not
|
|
* laxness: legs are registered by modules, and a spec is validated in a
|
|
* process that may have booted before the module that owns the leg.
|
|
*/
|
|
async perform({ params, verify }) {
|
|
const registered = registries.announceLeg(params.leg)
|
|
if (!registered) {
|
|
// Terminal, not transient. A leg nobody registers will not appear
|
|
// between two attempts sixty seconds apart, and the honest cause — a
|
|
// module removed, or a typo the authoring form could not catch — is a
|
|
// thing a human fixes.
|
|
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
|
|
}
|
|
// A dry run reports what it WOULD do and sends nothing (§I). Answering
|
|
// before the dispatch rather than inside the leg is what keeps that true
|
|
// for legs written by people who never read this file.
|
|
if (verify) return { ok: true }
|
|
|
|
const result = await registered.dispatch({
|
|
title: params.title || null,
|
|
excerpt: params.body,
|
|
image_url: null,
|
|
})
|
|
// The leg's own classification, not a second opinion. `retry` vs
|
|
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
|
|
// already makes, and making it twice is how the two drift.
|
|
const { outcome, error } = registered.classify(result)
|
|
if (outcome === 'done') return { ok: true }
|
|
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
|
|
},
|
|
},
|
|
|
|
{
|
|
id: 'core.wait',
|
|
label: 'Wait',
|
|
description: 'Let a fixed amount of time pass before the next step of this phase runs.',
|
|
|
|
// `inspect` rather than `notify`: nothing is sent and nobody is told. It is
|
|
// the weakest class the closed set has for an action that is not a broadcast.
|
|
risk: 'inspect',
|
|
reversible: 'none',
|
|
version: 1,
|
|
|
|
params: [
|
|
{
|
|
name: 'seconds',
|
|
type: 'int',
|
|
required: true,
|
|
example: 300,
|
|
description: 'How long to wait. The runner sets the next step due_at from this.',
|
|
},
|
|
],
|
|
|
|
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
|
|
// NEXT step's `due_at`, which the runner owns, not something this function
|
|
// sleeps through. A `perform` that slept would hold a step's claim for the
|
|
// duration and turn a five-minute pause into a five-minute lease — and the
|
|
// reclaim would then re-dispatch it, so a long enough wait would never end.
|
|
//
|
|
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
|
|
// why the runner can honour this without knowing what `core.wait` is.
|
|
async perform({ params, verify }) {
|
|
if (verify) return { ok: true }
|
|
return { ok: true, holdFor: params.seconds }
|
|
},
|
|
},
|
|
|
|
{
|
|
id: 'core.cue',
|
|
label: 'Cue a human',
|
|
description:
|
|
'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
|
|
|
|
// The action itself only posts an instruction. Whatever the human then does
|
|
// is outside this system entirely, which is precisely why the cue exists:
|
|
// it is how an event uses a capability no module has automated.
|
|
risk: 'notify',
|
|
reversible: 'none',
|
|
version: 1,
|
|
|
|
params: [
|
|
{
|
|
name: 'instruction',
|
|
type: 'string',
|
|
required: true,
|
|
example: 'Open the north gate and read the herald script in Britain bank.',
|
|
description: 'What the staff member is being asked to do.',
|
|
},
|
|
{
|
|
name: 'assignee',
|
|
type: 'string',
|
|
required: false,
|
|
example: 'Event Team',
|
|
description: 'Who the cue is addressed to. A label, not an account.',
|
|
},
|
|
],
|
|
|
|
/**
|
|
* Post the instruction and PARK. The step does not complete here.
|
|
*
|
|
* `await: 'human'` is the envelope member that says so (org lead,
|
|
* 2026-09-02), and the runner's answer to it is to leave the step `running`
|
|
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
|
|
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
|
|
* The step ends when someone presses confirm, which is Phase 3's control.
|
|
*
|
|
* **Nothing is delivered from here in Phase 2, and that is visible rather
|
|
* than pretended.** The instruction is carried by the step's own params and
|
|
* shown on the run console; routing it to Discord or to a staff inbox is
|
|
* Phase 10's integration work, through the engagement triggers that own every
|
|
* other notification on this platform. An action that grew its own delivery
|
|
* path would be the second one.
|
|
*/
|
|
async perform({ verify }) {
|
|
if (verify) return { ok: true }
|
|
return { ok: true, await: 'human' }
|
|
},
|
|
},
|
|
|
|
{
|
|
id: 'core.lease',
|
|
label: 'Borrow a value',
|
|
description:
|
|
'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
|
|
|
|
// The world changes and it changes back, so `change` rather than
|
|
// `irreversible` — and `change`'s default `on_failure` is `pause`, which is
|
|
// the right stop for a run that failed halfway through altering the world.
|
|
risk: 'change',
|
|
// The one action core ships in this class. `override` is what tells the
|
|
// cleanup sweep to restore through the LEASE registry rather than through an
|
|
// action's `revert()`, which is why this action needs no `revert()` of its own
|
|
// and why the registry refuses one on it.
|
|
reversible: 'override',
|
|
version: 1,
|
|
|
|
params: [
|
|
{
|
|
name: 'lease',
|
|
type: 'string',
|
|
required: true,
|
|
example: 'uo.rate.skillgain',
|
|
source: 'core.options.leases',
|
|
description: 'Which declared value to borrow.',
|
|
},
|
|
{
|
|
// **A string, and the coercion is here rather than in the type system.**
|
|
// A param declares ONE type; a lease declares its own, and they are four
|
|
// different ones. Typing this `float` would make a boolean lease
|
|
// unauthorable and a string lease nonsense, so the field takes text and
|
|
// this action turns it into whatever the named lease said it holds — the
|
|
// one place that knows both halves.
|
|
name: 'value',
|
|
type: 'string',
|
|
required: true,
|
|
example: '3.0',
|
|
description: 'What to hold it at, in whatever type the lease declares.',
|
|
},
|
|
{
|
|
name: 'minutes',
|
|
type: 'int',
|
|
required: true,
|
|
example: 120,
|
|
description: 'How long to hold it. Core refuses more than the lease allows.',
|
|
},
|
|
],
|
|
|
|
// What a lease costs is the LEASE's business to bound, not a budget's:
|
|
// `maxDurationMs` and the numeric range are declared beside the callables and
|
|
// enforced below. A cap dimension here would be core inventing an accounting
|
|
// unit for something a module already bounds — and `registerEventBudgets`
|
|
// refuses a dimension nobody declared, which is exactly the rule that would
|
|
// then bite core's own action.
|
|
|
|
/**
|
|
* Read the baseline, reserve the target, apply the value.
|
|
*
|
|
* **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
|
|
* is knowable before the dispatch — it is the lease id the step names — so
|
|
* the ledger row is written with its real `kind` and `ref` BEFORE anything
|
|
* touches the world, and the two-events-one-target refusal comes from the
|
|
* unique index at that moment rather than from a check that read and then
|
|
* wrote. A second run asking for a lease another run holds comes back
|
|
* `refused`, in the same words a cap breach uses and for the same reason:
|
|
* nothing is broken, the deployment already has that value spoken for.
|
|
*
|
|
* The order is read then reserve then apply, and a failure at each stage
|
|
* undoes the one before it: a reservation whose `apply` refuses is released
|
|
* here rather than left for the sweep, because there is nothing out there to
|
|
* give back and a shard that is merely down must not lock a lease out for the
|
|
* length of a retry cycle.
|
|
*/
|
|
async perform({ runId, stepId, params, verify }) {
|
|
// eslint-disable-next-line global-require
|
|
const resourcesDb = require('../model/events/eventRunResources.db')
|
|
const lease = registries.eventLease(params.lease)
|
|
if (!lease) {
|
|
return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
|
|
}
|
|
|
|
const coerced = coerceLeaseValue(lease, params.value)
|
|
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
|
|
|
|
const minutes = Number(params.minutes)
|
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
|
|
}
|
|
const ms = Math.round(minutes * 60_000)
|
|
if (ms > lease.maxDurationMs) {
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
|
|
}
|
|
}
|
|
|
|
// **The dry run stops here, and it has still checked everything worth
|
|
// checking**: the lease exists, the value is in range and the duration is
|
|
// allowed. What it deliberately does not do is reserve the target — a
|
|
// verify that took a lease would be a dry run that changed something, and
|
|
// it would then refuse the real run that followed it.
|
|
if (verify) return { ok: true }
|
|
|
|
const baseline = await lease.read()
|
|
if (!baseline || baseline.ok !== true) {
|
|
return { ok: false, error: `could not read the current value of ${lease.label}` }
|
|
}
|
|
|
|
const until = new Date(Date.now() + ms)
|
|
const reserved = await resourcesDb.reserve({
|
|
runId,
|
|
stepId,
|
|
owner: lease.owner || 'core',
|
|
kind: 'override',
|
|
ref: lease.id,
|
|
payload: { target: lease.id, baseline: baseline.value, applied: coerced.value, until: until.toISOString() },
|
|
leaseUntil: until,
|
|
})
|
|
if (!reserved.ok) {
|
|
const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
|
|
return {
|
|
ok: false,
|
|
retry: false,
|
|
error: `${lease.label} is already leased by another run${heldBy}`,
|
|
}
|
|
}
|
|
|
|
// **`until` goes down the wire** (§F). The module passes it to its sidecar
|
|
// and the game side restores baseline when it passes, without being asked
|
|
// again — the fail-safe that makes an unattended, scheduled world change
|
|
// defensible, because the worst case is a world back at baseline early
|
|
// rather than one stuck changed indefinitely.
|
|
let applied
|
|
try {
|
|
applied = await lease.apply(coerced.value, until)
|
|
} catch (err) {
|
|
applied = { ok: false, error: err.message }
|
|
}
|
|
if (!applied || applied.ok !== true) {
|
|
await resourcesDb.markReverted(reserved.id)
|
|
return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
|
|
}
|
|
|
|
await resourcesDb.confirm(reserved.id)
|
|
// **The run now owes the world something, and something has to say so.**
|
|
// The generic path marks a run dirty when it records a module's reported
|
|
// resources; this action reserves its own row and never goes through it, so
|
|
// a run whose only resource was a lease would have kept `cleanup_status =
|
|
// 'not_required'` and never been swept. Found by the live walk, and the
|
|
// cleanup leg's own scan was widened to make the class impossible rather
|
|
// than only this instance.
|
|
// eslint-disable-next-line global-require
|
|
await require('../events/ledger').markRunDirty(runId)
|
|
return { ok: true }
|
|
},
|
|
},
|
|
]
|
|
|
|
// ── Core's own param option sources (§F, Phase 7) ──────────────────
|
|
//
|
|
// One, and it is core's half of the seam it hands a module on the same boot: a
|
|
// param's `source` names a registered option source, core asks it for values, and
|
|
// the authoring form renders a dropdown instead of a text box.
|
|
//
|
|
// **The legs are already a registry with labels in it**, so this costs nothing
|
|
// new — which is what makes it the right first exercise. `resolve()` is called
|
|
// per request rather than read once, for the same reason `core.announce` looks a
|
|
// leg up inside `perform()`: a leg registered by a module that booted after this
|
|
// file was evaluated must still appear, and a module uninstalled since must stop
|
|
// appearing.
|
|
const OPTION_SOURCES = [
|
|
{
|
|
id: 'core.options.legs',
|
|
label: 'Announce legs',
|
|
description: 'Every delivery leg registered on this deployment right now.',
|
|
async resolve() {
|
|
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
|
|
},
|
|
},
|
|
{
|
|
id: 'core.options.leases',
|
|
label: 'Borrowable values',
|
|
description: 'Every value a module has declared this deployment may lease.',
|
|
async resolve() {
|
|
return registries
|
|
.allEventLeases()
|
|
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
|
|
},
|
|
},
|
|
]
|
|
|
|
module.exports = { ACTIONS, OPTION_SOURCES }
|