feat(kit): the event contract, taught and built (chapter 5)
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s

The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.

**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.

**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.

**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.

Two things writing it found, both now in the chapter and beside the code:

  * **An idempotency key belongs on a command, never on a question.** The first
    draft keyed every call including the reads; an at-most-once store then
    answers every future read with the first one's reply, forever. The lease
    applied correctly and the module could no longer see it. Hence `ask` and
    `send` as two functions.

  * **A refusal's reason goes in `error`; core reads no other name.** The first
    draft used `detail`, on the strength of the one place EVENTS.md §H mentions
    it, and every refusal it produced was anonymous on the run console.

Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.

**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.

Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-08 18:10:18 -05:00
parent e9ca759227
commit f89044b42e
15 changed files with 1804 additions and 11 deletions

View File

@@ -0,0 +1,447 @@
// ── What an event author can reach for ────────────────────────────────────
//
// MODULE_API.md 1.10.0 and `website/EVENTS.md` §F. Four declarations, all
// optional, and together they are how a scheduled event on the website reaches
// into your game and comes back out again.
//
// **All of it is optional, and that is the contract's own posture, not a hedge.**
// A deployment with none of this installed still has a working event engine: it
// can announce, wait, cue a human and publish results, over core's own verbs.
// What these four add is the ability for an event to reach the GAME. A module
// that registers none of them costs its deployment a capability, never a boot.
//
// ── The order to read this file in ────────────────────────────────────────
//
// A BUDGET names a resource dimension core can bound. An OPTION SOURCE answers a
// dropdown on the authoring form. A LEASE is a value a run may BORROW, with a
// deadline. An ACTION is a verb a run may perform, and what it makes it OWNS
// until teardown.
//
// Four separate id spaces, each namespaced with your module id. `examplegame.beacons`
// as a budget and `examplegame.beacon.light` as an action are not a collision;
// reading them as one would forbid the most natural set of names you will ever write.
//
// ── Own versus borrow, and which one to build first ───────────────────────
//
// This file declares one of each on purpose, and if you only have time for one,
// **build the lease.** `EVENTS.md` §H is blunt about it: the lease is the
// primitive that travels and object creation is the special case. "Double the
// gather rate for the weekend" is the canonical community event in almost every
// game — set a value, hold it, put it back — while spawning creatures at a
// landmark is a shape one genre happens to have. A lease is also the cheaper
// thing to make safe, because the value you are replacing already existed and
// reading it first gives you a baseline for free.
//
// ── The four things that are invisible until an outage ────────────────────
//
// Everything below is ordinary except four rules, and all four are the kind that
// look like they are working right up until the day something is down. They are
// marked TRAP 1..4 where they bite. In short:
//
// 1. **No shape a failure can take reads as success**, and `retry: true` is the
// default — so `budgetMs` must EXCEED your transport's own timeout or your
// own `retry: false` is unreachable code. The reason a refusal gives goes in
// `error`; core reads no other name.
// 2. **Pass `idempotencyKey` through, unchanged, on every attempt** — and put
// it on a COMMAND, never on a question. It is the only thing standing
// between a retry and a second world change, and the only thing that can
// make a read permanently stale.
// 3. **Core records a resource BEFORE it is confirmed**, so `revert` will be
// called about things that may never have existed — and about nothing at
// all, with only a key.
// 4. **`cost` is priced before dispatch and never reconciled against what came
// back**, so an action that under-declares turns every cap into a lie.
const core = require('../core')
const sidecar = require('../sidecarClient')
const clanDb = require('../model/clans/clanProvider.db')
const log = core.logger('events')
// ── TRAP 1 ────────────────────────────────────────────────────────────────
//
// Core's dispatcher enforces `budgetMs`. When it expires the dispatcher stops
// waiting and classifies the failure as **retry**, unconditionally, without
// asking the action — it cannot ask, the action is still awaiting a socket.
//
// So an action whose own client gives up AFTER core's deadline never gets to
// classify its own failure, and every `retry: false` it might return is
// unreachable code. Core's default `budgetMs` is 10s; this module's client waits
// 12s; on the default the deadline would fire first on every slow game and the
// step would be retried by core no matter what this file says.
//
// Hence: strictly greater than `sidecar.TIMEOUT_MS`, derived from it rather than
// typed beside it, and asserted in `test/eventActions.test.js`. Deriving it is
// the part worth copying — a constant typed twice drifts the first time somebody
// tunes the client and does not think to look here.
const BUDGET_MS = sidecar.TIMEOUT_MS + 3000
// How many beacons one step may ask for. A bound in the module, in front of the
// operator's cap rather than instead of it: this one is what the GAME can stand,
// and the cap is what this deployment allows. Pre-checking here is what lets a
// dry run show an author the refusal rather than a run meeting it at 3am.
const MAX_BEACONS = 25
// Statuses the far end uses to mean "this will never work". Everything else —
// including a timeout, a transport error and anything unrecognised — is left to
// the default, which is a retry. That direction is deliberate: see the envelope
// note on `classify` below.
const PERMANENT = new Set(['unknown-command', 'no-idempotency-key'])
/**
* One place that turns a client reply into an envelope core understands.
*
* Worth having as a function even with two callers. The rule it encodes —
* "unrecognised means retry" — is the one you want stated once, because the
* failure mode of getting it wrong per-action is a verb that quietly stops
* retrying and nobody notices until a shard reboots mid-event.
*/
function classify(answer) {
// **The field is `error`, not `detail`.** Core's dispatcher reads exactly two
// things off a failure envelope — `ok` and `retry` — and passes `error`
// through as the message an operator sees on the run console and an author
// sees on a dry run. Anything under another name is dropped in silence, so an
// action that puts its reason in `detail` produces a refusal that reads
// "<action id> refused" and tells nobody why. Writing this template is how
// that was found: `EVENTS.md` §H names a `detail` member in passing and core
// has never read one.
return { ok: false, retry: !PERMANENT.has(answer.status), error: answer.status }
}
// ══ BUDGETS ═══════════════════════════════════════════════════════════════
//
// A dimension core can count and bound. Core never learns what a beacon is: it
// holds `{ dimension, consumed, cap }` and the vocabulary stays here. That is the
// whole of what makes the engine game-agnostic at this seam.
//
// **Declaring a dimension is not the same as bounding it.** A declared dimension
// with no operator cap is counted and unbounded — which is useful on its own,
// because the run console then shows an author what their event actually spent.
const BUDGETS = [
{ id: 'examplegame.beacons', label: 'Beacons lit', unit: 'count' },
]
// ══ OPTION SOURCES ════════════════════════════════════════════════════════
//
// What a dropdown on the authoring form is filled from. A fourth registration
// rather than a field on the action, because a catalog usually has more than one
// consumer — this one answers both the action's `clanId` param and the lease's
// target would, if the lease were targeted — and two actions declaring it
// separately would be two allowlists that can disagree.
//
// **A source that refuses degrades its field to free text with a warning.** It
// never blocks the form and it never raises, so this resolver may read the
// database and may fail. Do not defend against that by returning a hardcoded
// list; an empty answer with a log line is more honest than a stale one.
const OPTION_SOURCES = [
{
id: 'examplegame.options.clans',
label: 'Clans',
// Core passes `q` to EVERY source and requires it of none, so a resolver
// written before search existed behaves identically. Declare
// `searchable: true` when the term actually narrows the answer — the form
// reads it to choose between a typeahead and a select. Do not infer it from
// the length of the list: that reads correctly right up until a small
// deployment's list happens to fit in a dropdown.
async resolve() {
try {
const clans = await clanDb.listClans()
return clans.map((c) => ({ value: c.externalId, label: c.name }))
} catch (err) {
log.warn('option source failed', { source: 'examplegame.options.clans', error: err.message })
return []
}
},
},
]
// ══ LEASES ════════════════════════════════════════════════════════════════
//
// A value a run BORROWS and gives back. The module declares what can be held and
// how long; **the verb is core's** — an author puts `core.lease` in a step, and
// core reads the baseline, reserves the target in its resource ledger, applies
// the value with a deadline, and restores it at teardown through `restore()`
// below. A lease verb of your own would be that duration bound and that
// two-events-one-target check re-implemented once per module, advisory
// everywhere, and wrong in the first one that forgot it.
//
// **Only advertise a lease you have verified takes effect.** A value your game
// reads once at start-up and caches will apply cleanly, read back cleanly and do
// nothing — a capability that lies, which no amount of core-side checking can
// catch. Apply it, observe it, restore it, as a test, per key.
const LEASES = [
{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float',
min: 0.5,
max: 5,
// The longest core will let a run hold it. A weekend, here. The bound is
// core's to enforce and yours to choose, and it should be the longest you
// would be comfortable finding still applied after everything else broke.
maxDurationMs: 48 * 60 * 60 * 1000,
/** The baseline, read live. Core stores what this answers and restores to it. */
async read() {
// `ask`, not `send`. A read carrying an idempotency key would be answered
// with the FIRST read's value forever — see `sidecarClient.js`'s header.
const answer = await sidecar.ask('rate.gather.read')
return answer.ok ? { ok: true, value: answer.data.value } : classify(answer)
},
/**
* Hold the value until `until`.
*
* **`until` goes down the wire and the far end honours it without being asked
* again.** Core's copy of the deadline is for the console; the game's copy is
* the fail-safe. A module that passes it and then relies on core to come back
* and restore has built a lease that outlives an outage — which is the one
* thing a lease exists to prevent.
*/
async apply(value, until) {
// No idempotency key, and that is deliberate rather than an omission:
// setting a value to X twice is setting it to X. A key here would buy
// nothing and cost the reply's freshness.
const answer = await sidecar.send('rate.gather.apply', {
value,
until: until instanceof Date ? until.toISOString() : until,
})
return answer.ok ? { ok: true } : classify(answer)
},
/**
* Put it back.
*
* `expected` is what core believes is currently applied. Answering that the
* live value differs is how a lease lands `drifted` with the current value
* beside it, rather than core silently overwriting whatever a human changed
* mid-event. Restoring must be idempotent for the same reason `revert` must:
* core may ask more than once.
*/
async restore(baseline, { expected } = {}) {
const live = await sidecar.ask('rate.gather.read')
if (!live.ok) return classify(live)
if (expected !== undefined && Number(live.data.value) !== Number(expected)) {
return { ok: true, drifted: true, value: live.data.value }
}
const answer = await sidecar.send('rate.gather.restore', { value: baseline })
return answer.ok ? { ok: true } : classify(answer)
},
/**
* A FOURTH question, not a fourth spelling of `read()`.
*
* "Does the game side still have any record of this hold?" A value that
* DIFFERS from what the run applied is drift, which `restore()` reports; a
* reconcile that inferred absence from a changed value would take the row out
* and tell an operator the lease vanished rather than that somebody moved it.
*
* Optional, and answering `{ ok: true, held: false }` is the only thing that
* takes a lease's ledger row out. Everything else — a throw, a refusal, no
* `inForce` at all — leaves the row alone, which is the same
* "I do not know is never it is gone" rule the actions below follow.
*/
async inForce() {
const live = await sidecar.ask('rate.gather.read')
if (!live.ok) return classify(live)
return { ok: true, held: Number(live.data.value) !== 1.0 }
},
},
]
// ══ ACTIONS ═══════════════════════════════════════════════════════════════
const ACTIONS = [
{
id: 'examplegame.beacon.light',
label: 'Light beacons',
description: "Lights signal beacons at a clan's hall for the length of this event.",
// Both are closed sets core interprets, and neither is decoration: `risk`
// decides which role may put this in a step and whether it is off by default,
// and `reversible` decides whether core will ever call `revert`.
risk: 'change', // notify | inspect | change | irreversible
reversible: 'ledger', // none | self | ledger | override
version: 1,
budgetMs: BUDGET_MS, // TRAP 1 — see the constant
// ── TRAP 4 ──────────────────────────────────────────────────────────
//
// What ONE invocation consumes. A function, because it depends on the params.
//
// **Core prices this BEFORE dispatch and never reconciles it against what
// came back.** There is no check that the `resources` you return match what
// you said you would spend — there cannot be, since core does not know what a
// beacon is. So an action that returns `{ 'examplegame.beacons': 1 }` while
// lighting twelve turns an operator's cap of 30 into a cap of 360, and the
// meter on the run console agrees with the lie. Nothing goes red. The first
// symptom is a world with an order of magnitude more in it than anyone
// authorised.
//
// Count what you will actually make, from the params you were given, every
// time. If you cannot know until the answer comes back, declare the maximum:
// a spend that is too high refuses an event that would have fit, which an
// author can see and argue with, and one that is too low cannot be seen at all.
//
// A `cost()` naming a dimension no module declared is REFUSED — at save, at
// the dry run and at dispatch — because the fix is a module's declaration and
// not a deployment's cap.
cost: (p) => ({ 'examplegame.beacons': Number(p.count) || 0 }),
params: [
{
name: 'clanId',
type: 'string',
required: true,
// `example` is required on every param, optional ones included. It is the
// authoring form's placeholder, it is one word at declaration time, and
// it is unreconstructable afterwards by anybody who did not write the action.
example: 'clan-1',
source: 'examplegame.options.clans',
},
{ name: 'count', type: 'int', required: true, example: 6 },
],
/**
* Do it.
*
* @param {object} env
* @param {string} env.runId
* @param {string} env.stepId
* @param {string} env.idempotencyKey a function of identity, never of attempt
* @param {*} env.scope opaque to core; may be null
* @param {object} env.params
* @param {object} env.actor
* @param {boolean} env.verify dry run: validate, change NOTHING
*/
async perform({ idempotencyKey, params, verify }) {
const count = Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > MAX_BEACONS) {
// A refusal the second attempt would repeat verbatim, so `retry: false`.
// This is the arm TRAP 1 exists to keep reachable.
return { ok: false, retry: false, error: `count must be 1..${MAX_BEACONS}` }
}
// **`verify` must change nothing and must answer honestly.** It rides this
// same dispatcher a real run uses, because a dry run down a second code
// path is a dry run of the second path. Validate everything you can reach
// without writing — the params above, and a lookup below — then stop.
if (verify) {
const clan = await clanDb.findClan(params.clanId)
return clan
? { ok: true }
: { ok: false, retry: false, error: `no such clan: ${params.clanId}` }
}
// ── TRAP 2 ────────────────────────────────────────────────────────
//
// The key goes through, unchanged. Core derives it from the step's identity
// and never from the attempt number, so every retry carries the same one —
// and the far end, which is the only end that can tell a retry from a
// repeat, answers a key it already executed with the ORIGINAL reply rather
// than running it again.
//
// A module that generates its own key here, or drops it, has an action that
// cannot be retried safely, and the cost of that is not a failed step: it
// is a second world change on a socket hiccup. It looks like it works in
// every test, because in every test the first attempt succeeds.
const answer = await sidecar.send(
'beacon.light',
{ clanId: params.clanId, count },
{ idempotencyKey },
)
if (!answer.ok) return classify(answer)
// What core writes into its ledger. `kind` is yours; `ref` is whatever you
// will need to undo it. The boot stamp rides along because `reconcile`
// below is the only thing that reads it — see its note.
return {
ok: true,
resources: answer.data.refs.map((ref) => ({
kind: 'beacon',
ref,
meta: { bootId: answer.data.bootId },
})),
}
},
/**
* Undo it. **Required, because `reversible` is `'ledger'`.**
*
* Called by core's cleanup sweep at teardown, over the rows this action's
* `resources` produced — a LIST, so twelve beacons are one round trip rather
* than twelve. Cleanup is derived rather than authored: there is no
* `on_teardown` and no cleanup phase in a spec, because an operator cannot be
* relied on to write the undo and an aborted run never reaches the phase they
* wrote it in. It runs on every terminal path — completion, cancellation and
* abort alike.
*
* ── TRAP 3 ──────────────────────────────────────────────────────────
*
* Two things follow from `EVENTS.md` §D rule 1, *core records a resource
* BEFORE it is confirmed*:
*
* • **Reverting something that does not exist is a SUCCESS.** A dispatch
* whose answer was lost leaves a ledger row for something that may never
* have existed, and cleanup will ask about it. You must never have to
* tell "I removed it" from "it was not there" — and you could not, because
* the far end cannot either. Answer `{ ok: true }`.
*
* • **You will be called with NO resources and only a key.** That is the
* lost-answer case stated exactly: core knows a dispatch went out under
* this 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, not a
* silent one. Answering `{ ok: true }` to a question you cannot answer is
* how a beacon burns forever with core's ledger reporting it cleaned up.
*
* And it must be idempotent, because core may ask more than once.
*/
async revert({ resources, idempotencyKey }) {
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
if (refs.length === 0) {
// The lost-answer case. This module CAN answer it, because the far end
// stores what each key produced — so asking it to undo the key is a real
// question with a real answer. If yours cannot, say `{ ok: false }` here
// and let a human see the row.
const byKey = await sidecar.send('beacon.douse', { refs: [] }, { idempotencyKey })
return byKey.ok ? { ok: true } : classify(byKey)
}
const answer = await sidecar.send('beacon.douse', { refs }, { idempotencyKey })
if (!answer.ok) return classify(answer)
// `{ ok: true }` reverts the whole group. Name the ones that did not come
// back in `failed: [...]` and core keeps exactly those rows.
return { ok: true }
},
/**
* Which of these does the game still have? **Optional.**
*
* Asked after something outside core restarted — core's own boot, or this
* module calling `core.reconcileEvents()` because it saw the boot id change.
*
* The asymmetry with `revert` is the design: a module that cannot say what the
* game still has is not broken, and core keeps believing its own ledger. One
* that created something and cannot undo it has made a promise core has no way
* to keep. So `revert` is required and this is not.
*
* **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 reported missing becomes `orphaned` rather than `reverted` —
* because nobody asked for it to go.
*/
async reconcile({ resources }) {
const refs = (resources || []).map((r) => r.ref).filter(Boolean)
// A question, so `ask`. Keying this one would have pinned the answer to
// whatever was in force the first time core ever swept — which is the exact
// opposite of what a reconcile is for.
const answer = await sidecar.ask('beacon.inForce', { refs })
if (!answer.ok) return classify(answer)
return { ok: true, inForce: answer.data.refs }
},
},
]
module.exports = { BUDGETS, OPTION_SOURCES, LEASES, ACTIONS, BUDGET_MS, MAX_BEACONS, classify }