Files
website/server/src/config/coreEventActions.js
wtclaude fd9fb50351
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s
feat(events): open the event contract to modules (Phase 7)
MODULE_API 1.10.0. Four names forwarded on the module-facing `api` --
registerEventActions, registerEventBudgets, registerEventLeases and
registerEventOptionSources -- one new route, and one rule made real: a
`cost()` naming a dimension no module declared is refused.

Only one of the four is new machinery. The action registry has staged
core's three actions on every boot since Phase 1; what it never had was a
way in, because loader.js builds its own `api` facade and had no method
that delegated to it. So the registry a module now reaches is one that has
been exercised on every boot for six phases.

Four decisions, settled 2026-09-03, all as recommended:

- Option sources are their own registration, modelled on registerAudiences,
  because a catalog has more than one consumer.
- An undeclared dimension is refused -- at save, at the dry run and at
  dispatch -- with its own code, because the fix is a module's declaration
  and not a deployment's cap.
- A lease is declared here and acquired by nothing; the ledger is Phase 8.
- Core registers core.options.legs, so an announce leg is a dropdown rather
  than the free-text box whose typo Phase 6's walk caught mid-run.

Proved with a throwaway module through the real loader, not with module-uo:
eventModuleContract.test.js writes a module to a real directory and lets the
loader scan it, covering all five envelope failure shapes, verify: true, the
four id spaces and dormancy on uninstall.

The live walk found the one defect nothing else could: the option-source
loader wrote its "already asked?" guard inside a setState updater and read
it on the next line, so the request was never made and the field sat on
"Reading the list..." for ever. It is a useRef now.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 14:15:04 -05:00

243 lines
11 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 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.
//
// **`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' }
},
},
]
// ── 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 }))
},
},
]
module.exports = { ACTIONS, OPTION_SOURCES }