feat(kit): the event contract, taught and built (Phase 15) #10

Merged
whitlocktech merged 1 commits from feature/events-p15-event-contract into main 2026-09-08 23:27:11 +00:00
Member

The fifth chapter, and the template code it teaches out of. EVENTS_PLAN.md Phase 15.

⚠️ CI is RED on checkCoreApi, and that is the mechanism working

template/module.json now declares coreApi: "^1.10.0". ci/core-ref.json pins the engagement cutover, where main is still on 1.9.0. checkCoreApi.js asserts equality, not "satisfies" — a contract bump is meant to turn this repo red until someone re-reads the chapters.

Do not "fix" it. The pin move is the kit's leg of the events cutover (EVENTS_PLAN.md P16) and lands as its own commit on this branch once the events sha exists on main. Same shape Teams phase 11 used.

Everything else is green: 82 server tests, 20 client tests, check:imports, check:externals, check:swagger, and all three prose checks plus their own unit tests.

What is here

Chapter 5 — Making your module event-capable. The four declarations, and the four things a second module's author will get wrong, one section each: the envelope's failure default, the idempotency passthrough, recording a resource before confirming it, and under-declaring cost. It leads with the lease rather than the spawn, on EVENTS.md §H's own argument — hold-a-value-for-a-weekend is every game, spawning creatures at a landmark is one genre.

Chapters 3 and 4 gain one section each. This was the finding that made the phase bigger than planned: the book as it stood taught a read-only data path end to end. Nothing in it told anyone to build a command path, so a chapter 5 teaching a module to send an idempotency key would have been addressing it to a sidecar with nowhere to put it. Chapter 3 gets §2a (request/reply correlation, the at-most-once store belonging where the state is, a deadline sent as a duration); chapter 4 gets "A command that changes the world runs at most once" (the key store persisted in the world save, the ownership registry, the expiry that side arms and re-arms at load). Both open by saying they are skippable until you want chapter 5.

The template ships one of each declaration — a budget, an option source, a lease, and one action that ledgers — plus server/sidecarClient.js, the near end of the call. Real timeout, real key passthrough, simulated transport in one function marked for replacement. The file is named for the filename noGameConnection.test.js already anticipated in its own header, so that test stays green today and fires correctly the moment deliver() becomes a request.

test/eventActions.test.js gives each of the four traps a test that fails if the rule is broken, not one that asserts the current value — trap 1 in particular is an inequality between two constants in different files, which is the only form that survives somebody tuning the client.

Two defects this found, both in code written for this PR

Neither was found by writing prose. Both were found by running the template's real declarations through core's real registry and its real envelopes through core's real events/dispatch.js classifier at edge.

1. An idempotency key belongs on a command, never on a question. The first draft had one send() and every call carried a key, reads included. An at-most-once store answers a key it has already seen with the original reply — forever. So the second read of a value returned the first read's answer, and every one after it. The lease applied correctly, the game changed correctly, and the module could no longer see any of it: read() reported the pre-run baseline and inForce() said nothing was held. Hence ask() and send() as two functions. The narrower rule that falls out is in the chapter too: a key is for a write whose repetition would be a second effect, and a write that merely sets a value to X is idempotent by its own nature.

2. 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. classify() reads ok, retry, error, await, holdFor, resources and participants — nothing else — so every refusal the template produced arrived at the run console as "examplegame.beacon.light refused", telling an author nothing. Fixed here, with a regression test; §H corrected in docs#226.

Proof

registry at edge:  budgets/sources/leases/actions — all four accepted
classifier:        perform refusal → {"outcome":"terminal","error":"count must be 1..25"}
                   verify bad clan → {"outcome":"terminal","error":"no such clan: nope"}
                   module timeout  → {"outcome":"retry"}
                   unknown-command → {"outcome":"terminal"}
checkCoreApi:      RED — ^1.10.0 vs pinned core 1.9.0 (deliberate, see above)

Pairs with docs#226.

  • AI-assisted: written with Claude Code (Claude Opus 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

The fifth chapter, and the template code it teaches out of. `EVENTS_PLAN.md` Phase 15. ## ⚠️ CI is RED on `checkCoreApi`, and that is the mechanism working `template/module.json` now declares `coreApi: "^1.10.0"`. `ci/core-ref.json` pins the engagement cutover, where `main` is still on `1.9.0`. `checkCoreApi.js` asserts **equality**, not "satisfies" — a contract bump is *meant* to turn this repo red until someone re-reads the chapters. **Do not "fix" it.** The pin move is the kit's leg of the events cutover (`EVENTS_PLAN.md` P16) and lands as its own commit on this branch once the events sha exists on `main`. Same shape Teams phase 11 used. Everything else is green: 82 server tests, 20 client tests, `check:imports`, `check:externals`, `check:swagger`, and all three prose checks plus their own unit tests. ## What is here **Chapter 5 — [Making your module event-capable](book/05-events.md).** The four declarations, and the four things a second module's author will get wrong, one section each: the envelope's failure default, the idempotency passthrough, recording a resource *before* confirming it, and under-declaring `cost`. It leads with the **lease** rather than the spawn, on `EVENTS.md` §H's own argument — hold-a-value-for-a-weekend is every game, spawning creatures at a landmark is one genre. **Chapters 3 and 4 gain one section each.** This was the finding that made the phase bigger than planned: the book as it stood taught a **read-only** data path end to end. Nothing in it told anyone to build a command path, so a chapter 5 teaching a module to send an idempotency key would have been addressing it to a sidecar with nowhere to put it. Chapter 3 gets §2a (request/reply correlation, the at-most-once store belonging where the state is, a deadline sent as a *duration*); chapter 4 gets *"A command that changes the world runs at most once"* (the key store persisted in the world save, the ownership registry, the expiry that side arms and re-arms at load). Both open by saying they are skippable until you want chapter 5. **The template ships one of each declaration** — a budget, an option source, a lease, and one action that ledgers — plus `server/sidecarClient.js`, the near end of the call. Real timeout, real key passthrough, simulated transport in **one** function marked for replacement. The file is named for the filename `noGameConnection.test.js` already anticipated in its own header, so that test stays green today and fires *correctly* the moment `deliver()` becomes a request. `test/eventActions.test.js` gives each of the four traps a test that **fails if the rule is broken**, not one that asserts the current value — trap 1 in particular is an inequality between two constants in different files, which is the only form that survives somebody tuning the client. ## Two defects this found, both in code written for this PR Neither was found by writing prose. Both were found by running the template's real declarations through **core's real registry** and its real envelopes through **core's real `events/dispatch.js` classifier** at `edge`. **1. An idempotency key belongs on a command, never on a question.** The first draft had one `send()` and every call carried a key, reads included. An at-most-once store answers a key it has already seen with the *original* reply — forever. So the second read of a value returned the first read's answer, and every one after it. The lease applied correctly, the game changed correctly, and the module could no longer see any of it: `read()` reported the pre-run baseline and `inForce()` said nothing was held. Hence `ask()` and `send()` as two functions. The narrower rule that falls out is in the chapter too: a key is for a write whose repetition would be a second *effect*, and a write that merely *sets* a value to X is idempotent by its own nature. **2. 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. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`, `resources` and `participants` — nothing else — so every refusal the template produced arrived at the run console as `"examplegame.beacon.light refused"`, telling an author nothing. Fixed here, with a regression test; §H corrected in docs#226. ## Proof ``` registry at edge: budgets/sources/leases/actions — all four accepted classifier: perform refusal → {"outcome":"terminal","error":"count must be 1..25"} verify bad clan → {"outcome":"terminal","error":"no such clan: nope"} module timeout → {"outcome":"retry"} unknown-command → {"outcome":"terminal"} checkCoreApi: RED — ^1.10.0 vs pinned core 1.9.0 (deliberate, see above) ``` Pairs with **docs#226**. - [x] AI-assisted: written with Claude Code (Claude Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-08 23:12:22 +00:00
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
f89044b42e
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
whitlocktech merged commit a72b002f75 into main 2026-09-08 23:27:11 +00:00
whitlocktech deleted branch feature/events-p15-event-contract 2026-09-08 23:27:14 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Integration-kit#10
No description provided.