feat(events): schema, CRUD and the core action registry (Phase 1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m24s

EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.

**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.

Schema (`db/schema.sql`, append-only):
  event_series, event_definitions, event_versions, event_runs,
  event_run_steps, event_run_log. The four that need a writer —
  event_action_settings, event_run_budget, event_run_resources,
  event_run_participants — arrive with the phases that give them one.

Registry (`modules/registries.js` + `config/coreEventActions.js`):
  registerEventActions staging and commit, with its own id namespace, the
  closed risk and reversibility sets, revert() required iff and only iff
  reversible: 'ledger', a bounded budgetMs and a param shape whose every
  entry needs a type and an example. perform/revert/cost are stripped from
  everything the catalog serves. Core declares core.announce, core.wait and
  core.cue through the same staging area a module will use.

  It is reachable ONLY by registerCore(): loader.js builds its own api facade
  and has no method that delegates here, so no module can call it and
  MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.

Surface (13 routes under /api/v1/admin/events):
  Reads staff-wide; publish, archive and run creation admin-only from this
  phase per EVENTS.md §N2, even though the switchboard they will consult does
  not exist yet — a button that is admin-only later and open now is a gate
  nobody notices was missing. The live run controls and `verify` are absent
  rather than stubbed, because nothing is in flight yet.

Four things the build settled, all recorded in docs:
  - event_definitions gained a `spec` column. A draft's working copy cannot
    be an event_versions row: that table is immutable and a run pins one.
  - The spec validator must accept its own output. It added `actionVersion`
    and `dormant` and then refused them as unknown keys, which would have made
    the second save of any definition — and publish's re-validation —
    impossible. A test caught it; both are now accepted and recomputed.
  - A param's `example` is required, optional params included, matching
    registerEventTriggers. It is the authoring form's placeholder.
  - Two routes the §API-surface table did not name: GET /admin/events/:id and
    GET /admin/events/series.

Core's three perform() bodies answer { ok: false, retry: false } rather than
{ ok: true }: `ok: true` on an action that did nothing is a recorded world
change that did not occur, which is the exact mistake §F's failure default
exists to prevent.

`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.

Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).

Docs: RunicGateway/docs#209

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 23:29:07 -05:00
parent 6331b36c45
commit 8e03497eb3
23 changed files with 4714 additions and 1 deletions

View File

@@ -124,6 +124,20 @@ const triggers = new Map()
// trigger of the same name would be a collision between two unrelated things.
const audiences = new Map()
// action id → { owner, id, label, description, risk, reversible, version,
// budgetMs, params, cost, perform, revert } (EVENTS.md §F, Phase 1).
//
// **Its own id space**, like `audiences` above and for the same kind of reason:
// an action names a VERB and a trigger names an EVENT, so `uo.champ.start` as
// the thing a module can be asked to do and `uo.champ.start` as the thing that
// happened are two unrelated declarations that must not collide with — or
// silently satisfy — each other. Nothing cross-checks this map against the
// stream/trigger namespace, and nothing should.
//
// A Map, and read by id on the dispatch path exactly as `triggers` is; insertion
// order is what the admin catalog renders in.
const eventActions = new Map()
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
@@ -163,6 +177,10 @@ const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
// Audiences are their own id space (see the `audiences` Map), so they get their
// own constant even though the grammar is the same one.
const AUDIENCE_ID = EVENT_ID
// Likewise event actions (EVENTS.md §F, "Actions and budgets are their own id
// spaces"). One grammar, three namespaces — the constant is what makes the
// namespace visible at every use site.
const ACTION_ID = EVENT_ID
// A module's claim must carry its id. Core's ids are its own namespace, and the
// grandfathered names are the ones that predate all of this.
@@ -367,6 +385,33 @@ async function resolveAudience(id, params = {}) {
}
}
// ── Event actions (EVENTS.md §F) ───────────────────────────────────────────
/**
* Every declaration WITHOUT its callables — what the admin catalog serves.
*
* `perform`, `revert` 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)
/** One declaration, callables included. The runner's lookup (Phase 2). */
const eventAction = (id) => eventActions.get(id) || null
/**
* Does anyone register this id right now?
*
* The authoring path's question, and it is deliberately not `eventAction(id) !==
* null` at every call site: a step naming an action whose module is uninstalled
* is DORMANT, not an error (§F), and the difference between "never existed" and
* "not installed today" is a distinction only the caller can draw.
*/
const isEventAction = (id) => eventActions.has(id)
// ── Shape checks, run the moment a registrant calls ────────────────────────
//
// Split from the collision checks below on the same line PR 3 drew through
@@ -745,6 +790,170 @@ function checkAudienceShape(entry) {
}
}
// ── Event action shape (EVENTS.md §F) ──────────────────────────────────────
// Four values, closed, core-owned (§N6). Deliberately NOT "world-read" and
// "world-write", which are game words a chess ladder has no use for — and
// deliberately not extensible by a module, because the class is what core
// derives a step's `on_failure` from (§L) and a module that could invent
// `harmless` would be choosing its own retry policy.
const ACTION_RISKS = ['notify', 'inspect', 'change', 'irreversible']
// What core must know in order to clean up after a run (§L). `none` is gone once
// done; `self` undoes itself; `ledger` needs a `revert` over the rows core
// recorded; `override` is a lease, whose baseline core restores.
const ACTION_REVERSIBLE = ['none', 'self', 'ledger', 'override']
// The same six types a trigger variable uses. One vocabulary over both, because
// the authoring form that renders an action param and the template editor that
// renders a trigger variable are the same widget over the same six types, and a
// second list is a list that drifts.
const ACTION_PARAM_TYPES = VARIABLE_TYPES
// The param name grammar, shared with trigger variables for the same reason: a
// param ends up as a key in a JSON object an operator reads.
const PARAM_NAME = VARIABLE_NAME
// The default per-invocation deadline. Ten seconds is §F's own figure and it is
// the number the sidecar's own request timeout is set near — long enough for a
// round trip through a module, a sidecar and a game tick, short enough that a
// wedged action does not hold a step's claim past its lease.
const DEFAULT_BUDGET_MS = 10_000
// An hour. Not "unlimited by another name": the bound exists so that a typo in a
// declaration is a slow action rather than a step that never times out at all,
// and Phase 2's lease has to be longer than this to mean anything.
const MAX_BUDGET_MS = 3_600_000
function checkActionParam(actionId, entry, seen) {
const { name, type, required, example, description, source } = entry || {}
const where = `registerEventActions: ${actionId}`
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
seen.add(name)
if (!ACTION_PARAM_TYPES.includes(type)) {
throw new Error(`${where}: param "${name}" has unsupported type "${type}"`)
}
// REQUIRED, on every param including the optional ones, and it is the same
// argument `checkTriggerVariable` makes: without it the authoring form has no
// placeholder and the operator is typing into a blank box, which is exactly
// how an unattended world write comes to be scheduled with a typo in it. It is
// one word at declaration time and unreconstructable afterwards.
if (example === undefined || example === null || example === '') {
throw new Error(`${where}: param "${name}" needs an example (it is the authoring placeholder)`)
}
// A `source` names a module-served option endpoint, so the field is a dropdown
// of real values rather than a text box (§F "Param option sources"). It is
// checked as an id here and resolved nowhere yet — the endpoint that answers it
// is Phase 7's, and a `source` naming nothing degrades the field to free text
// with a warning rather than blocking the form.
if (source !== undefined && !ACTION_ID.test(source || '')) {
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
}
return {
name,
type,
required: Boolean(required),
example,
source: source === undefined ? null : source,
description: description || '',
}
}
/**
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert }])`.
*
* 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
* already someone's action? — waits for `apply()`.
*
* The copy at the end is explicit rather than a spread, like every other shape
* check in this file: this object is served to the admin catalog and is what a
* step's params are validated against, so anything not named here is not part of
* the contract and must not ride along.
*
* **Nothing here executes and nothing here may touch the database.** Registration
* runs under `routeManifest.js` and `swagger.js` against a dead pool
* (MODULE_API.md §2.2), and core's own registration is subject to the same rule
* as a module's.
*/
function checkEventActionShape(entry) {
const a = entry || {}
if (!ACTION_ID.test(a.id || '')) {
throw new Error(`registerEventActions: bad action id "${a.id}"`)
}
if (!a.label) throw new Error(`registerEventActions: action "${a.id}" has no label`)
// Both required with no default, for the reason a trigger's ceiling is: there
// is no safe value to guess. Defaulting `risk` to `notify` would give a world
// write the retry policy of a broadcast, and defaulting `reversible` to `none`
// would tell the cleanup generator there is nothing to undo.
if (!ACTION_RISKS.includes(a.risk)) {
throw new Error(
`registerEventActions: ${a.id} needs a risk class, one of ${ACTION_RISKS.join(', ')}`,
)
}
if (!ACTION_REVERSIBLE.includes(a.reversible)) {
throw new Error(
`registerEventActions: ${a.id} needs a reversible class, one of ${ACTION_REVERSIBLE.join(', ')}`,
)
}
if (typeof a.perform !== 'function') {
throw new Error(`registerEventActions: ${a.id} has no perform()`)
}
// §F: `revert` is required iff `reversible === 'ledger'`. Checked here rather
// than discovered at teardown, because the moment it matters is the moment a
// run has already created something and the answer "there is no undo" is the
// one answer cleanup cannot act on.
if (a.reversible === 'ledger' && typeof a.revert !== 'function') {
throw new Error(`registerEventActions: ${a.id} is reversible: 'ledger' but has no revert()`)
}
// The mirror check, and it is not pedantry: a `revert` on a `reversible:
// 'none'` action is a module author who believes their action can be undone
// and a cleanup generator that will never call it. Silence there is a promise
// core does not keep.
if (a.revert !== undefined && a.reversible !== 'ledger') {
throw new Error(
`registerEventActions: ${a.id} declares revert() but is reversible: '${a.reversible}'`,
)
}
if (a.cost !== undefined && typeof a.cost !== 'function') {
throw new Error(`registerEventActions: ${a.id} cost must be a function of its params`)
}
const version = a.version === undefined ? 1 : a.version
if (!Number.isInteger(version) || version < 1) {
throw new Error(`registerEventActions: ${a.id} has a bad version "${a.version}"`)
}
const budgetMs = a.budgetMs === undefined ? DEFAULT_BUDGET_MS : a.budgetMs
if (!Number.isInteger(budgetMs) || budgetMs <= 0 || budgetMs > MAX_BUDGET_MS) {
throw new Error(
`registerEventActions: ${a.id} budgetMs must be 1..${MAX_BUDGET_MS} ms, got "${a.budgetMs}"`,
)
}
if (a.params !== undefined && !Array.isArray(a.params)) {
throw new Error(`registerEventActions: ${a.id} params must be an array`)
}
const seen = new Set()
const params = (a.params || []).map((p) => checkActionParam(a.id, p, seen))
return {
id: a.id,
label: a.label,
description: a.description || '',
risk: a.risk,
reversible: a.reversible,
version,
budgetMs,
params,
cost: a.cost || null,
perform: a.perform,
revert: a.revert || null,
}
}
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
// **Two mechanisms, and the asymmetry between them is the whole design.**
@@ -983,6 +1192,7 @@ function stage(owner) {
slashCommands: [],
triggers: [],
audiences: [],
eventActions: [],
engagementSeeds: [],
}
return {
@@ -1015,6 +1225,16 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
for (const e of entries) staged.audiences.push(checkAudienceShape(e))
},
// EVENTS.md §F, Phase 1. Present on the staging area from this phase and
// reached ONLY by `registerCore()` below — `loader.js` builds its own `api`
// facade and has no method that delegates here, so a module cannot call this
// yet. Phase 7 adds that facade and bumps MODULE_API to 1.10.0; until then
// the seam is exercised on every boot by core's own three actions and by
// nothing else, which is the point of registering them through it.
registerEventActions(entries) {
if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
},
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
@@ -1040,6 +1260,7 @@ function apply({
slashCommands: newSlashCommands = [],
triggers: newTriggers = [],
audiences: newAudiences = [],
eventActions: newEventActions = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
@@ -1096,6 +1317,23 @@ function apply({
seenAudiences.add(a.id)
}
// Actions, against their OWN map and nothing else. No cross-facet check with
// streams or triggers: an action id and a trigger id are different namespaces
// (§F), so `uo.champ.start` may legitimately be both a verb and an event, and
// reading a collision there would forbid the most natural pair of names a
// module will ever want. No legacy allowlist either — nothing predates this,
// so the prefix rule has no exceptions and should never grow one.
const seenActions = new Set()
for (const a of newEventActions) {
const held = eventActions.get(a.id)
if (held) throw new Error(`event action "${a.id}" is already registered by "${held.owner}"`)
if (seenActions.has(a.id)) throw new Error(`event action "${a.id}" registered twice`)
if (!namespaced(owner, a.id, {})) {
throw new Error(`event action "${a.id}" is not namespaced "${owner}."`)
}
seenActions.add(a.id)
}
const seenLegs = new Set()
for (const l of newLegs) {
const held = legs.get(l.leg)
@@ -1160,6 +1398,7 @@ function apply({
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
for (const a of newEventActions) eventActions.set(a.id, { owner, ...a })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
}
@@ -1182,6 +1421,7 @@ function registerCore() {
/* eslint-disable global-require */
const coreStreams = require('../config/coreStreams')
const coreTriggers = require('../config/coreTriggers')
const coreEventActions = require('../config/coreEventActions')
const discordLeg = require('../utils/discordAnnounce')
/* eslint-enable global-require */
@@ -1192,6 +1432,11 @@ function registerCore() {
// its five stream ids — the same-owner upgrade the one-namespace rule above is
// written for — so this batch exercises the cross-facet check on every boot.
api.registerEventTriggers(coreTriggers.TRIGGERS)
// The event contract (EVENTS.md §F, Phase 1). Core registers `core.announce`,
// `core.wait` and `core.cue` through the SAME staging area Phase 7 will hand a
// module, so the registry is exercised on every boot long before a module uses
// it — the argument registerCore() has made since the module system's Phase 3.
api.registerEventActions(coreEventActions.ACTIONS)
// The three lines that used to follow — the shard stream catalog, the town
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
@@ -1205,6 +1450,7 @@ function registerCore() {
log.info('core registrations complete', {
streams: streams.length,
eventTriggers: triggers.size,
eventActions: eventActions.size,
announceLegs: legs.size,
extensions: [...slots.keys()].filter(slotFilledBy),
})
@@ -1236,6 +1482,7 @@ function _reset() {
slashCommands.clear()
triggers.clear()
audiences.clear()
eventActions.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -1264,11 +1511,18 @@ module.exports = {
allAudiences,
audience,
resolveAudience,
allEventActions,
eventAction,
isEventAction,
allEngagementSeeds,
engagementSeedsFor,
SEEDABLE_CHANNELS,
VARIABLE_TYPES,
TRIGGER_KINDS,
ACTION_RISKS,
ACTION_REVERSIBLE,
ACTION_PARAM_TYPES,
DEFAULT_BUDGET_MS,
stage,
apply,
registerCore,