// ── 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 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. const registries = require('../modules/registries') 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. name: 'leg', type: 'string', required: true, example: 'discord', 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' } }, }, ] module.exports = { ACTIONS }