feat(kit): the engagement contract, taught and built — cutover 5 of 7 #9

Merged
whitlocktech merged 1 commits from feat/engagement-contract into main 2026-09-01 18:03:53 +00:00
Member

Engagement Phase 13, step 5 of 7 — the step the cutover order named and skipped. Steps 1–4 and 6 are merged; 7 (runicgateway.com) is next.

The kit was pinned to website 963d734MODULE_API 1.6.0, the Teams cutover — and the platform is on 1.9.0. Three registrations and two calls arrived in between, and a reader building against this book would have found no mention of any of them.

ci/core-ref.json      963d734  (1.6.0)  →  66bb3b9a  (1.9.0, the engagement cutover)
template/module.json  ^1.6.0            →  ^1.9.0

checkCoreApi is green against the new pin. That check is equality on purpose, and it is worth naming what it does not do: it clones this ref, so a core that moves past the pin never turns the repo red on its own. Nothing goes red until someone moves the pin. Between cutovers the kit is not wrong, it is dated — and this file is where the date is written down.

What chapter 2 gained

A new section, Telling core something happened, and it is the seam where a module is most tempted to reach past the boundary. The rule it turns on is one sentence: you declare what CAN happen; core decides who is told.

  • A trigger is a payload contract, not a notification stream. Both are catalogs of things that happen in your game and they share one id namespace, so the distinction has to be taught rather than inferred: a stream is a subscribe toggle you publish to; a trigger is what an operator writes rules against, and core does the sending.
  • ceiling is required, has no default, and is a containment tree rather than a size ladder. A staff ceiling does not permit owner — "one person" for a cheat-detection event is the player it was detected on. Fewer people is not less exposure.
  • An audience resolver returns user ids and nothing else, resolves to nobody on failure rather than to everybody, and takes constant params. That last one is the constraint most worth knowing before you design around it: there is no way to say "the clan this event was about".
  • Templates re-ensure per seedVersion; rule groups are offered once, per group key. So a rule appended to an existing group reaches fresh installs only — the guarantee, not a limitation to route around.
  • ctx.events.emit binds the owner from the calling module; ctx.inbox.push is the direct write, for when there is nothing for an operator to decide.

What the template builds

One trigger, one audience over the clan roster it already had, one seeded body, one seeded rule group, and an emitter in boot.js that fires on the transition, not on the poll — with the two traps inside that function (read before write; a first boot is not a change) written where someone copying it will read them. Five new tests (the server suite goes 47 → 52), including the audience that resolves to nobody when its query throws, the rule that a seeded rule may not seed itself enabled, and the block-shape check below — which was verified by breaking it.

Three claims the re-read found wrong, corrected here rather than shipped

  1. Core validates subjectKey against the declared variables and refuses the module. The draft taught a cooldown silently keyed on undefined — the thing the check exists to prevent, and a consequence a reader will never actually meet.
  2. emit throws outside production and only drops-and-logs inside it. Teaching the second half alone leaves a developer meeting a throw the book says cannot happen.
  3. The seeded body was malformed — heading level: 2 where the block registry takes 'h2', and no block ids at all.

The third is the one worth keeping. registerEngagementSeeds checks that blocks is a non-empty array and stops; the body is validated by the block registry, which runs in the editor and the renderer. That body would have registered, seeded, and failed the first time an operator opened it.

It was found by running the template's register() through core's real registry at the pinned ref — which CI does not do and cannot: the template job checks the version and runs the template against fakes, and a fake accepts what core refuses. So the gap is now named three times over — in the chapter, beside the code, and in the pin's own comment, which says a pin move is a run against a real core rather than an edit here — and the rule that bit has a test that fails on it (verified by breaking it).

One check fixed

checkLinks walked .core/. Bumping this pin means cloning core into that directory first — .gitignore reserves the name for exactly that — and the walk then reported nine broken links in someone else's README. CI never saw it: the clone happens in the template job and the check runs in prose. A failure only a person could meet, on the path this PR asks people to walk.

  • AI-assisted: written with Claude Code.

🤖 Generated with Claude Code

**Engagement Phase 13, step 5 of 7** — the step the cutover order named and skipped. Steps 1–4 and 6 are merged; 7 (`runicgateway.com`) is next. The kit was pinned to website `963d734` — **MODULE_API 1.6.0**, the Teams cutover — and the platform is on **1.9.0**. Three registrations and two calls arrived in between, and a reader building against this book would have found no mention of any of them. ``` ci/core-ref.json 963d734 (1.6.0) → 66bb3b9a (1.9.0, the engagement cutover) template/module.json ^1.6.0 → ^1.9.0 ``` `checkCoreApi` is green against the new pin. That check is equality on purpose, and it is worth naming what it does *not* do: it clones **this** ref, so a core that moves past the pin never turns the repo red on its own. Nothing goes red until someone moves the pin. Between cutovers the kit is not wrong, it is **dated** — and this file is where the date is written down. ### What chapter 2 gained A new section, *Telling core something happened*, and it is the seam where a module is most tempted to reach past the boundary. The rule it turns on is one sentence: **you declare what CAN happen; core decides who is told.** - A **trigger** is a *payload contract*, not a notification stream. Both are catalogs of things that happen in your game and they share one id namespace, so the distinction has to be taught rather than inferred: a stream is a subscribe toggle you publish to; a trigger is what an operator writes rules against, and core does the sending. - **`ceiling` is required, has no default, and is a containment tree rather than a size ladder.** A `staff` ceiling does not permit `owner` — "one person" for a cheat-detection event is *the player it was detected on*. Fewer people is not less exposure. - An **audience** resolver returns user ids and nothing else, **resolves to nobody** on failure rather than to everybody, and takes **constant** params. That last one is the constraint most worth knowing before you design around it: there is no way to say "the clan this event was about". - **Templates re-ensure per `seedVersion`; rule groups are offered once, per group key.** So a rule appended to an existing group reaches **fresh installs only** — the guarantee, not a limitation to route around. - `ctx.events.emit` binds the owner from the calling module; `ctx.inbox.push` is the direct write, for when there is nothing for an operator to decide. ### What the template builds One trigger, one audience over the clan roster it already had, one seeded body, one seeded rule group, and an emitter in `boot.js` that fires **on the transition, not on the poll** — with the two traps inside that function (read before write; a first boot is not a change) written where someone copying it will read them. Five new tests (the server suite goes 47 → 52), including the audience that resolves to nobody when its query throws, the rule that a seeded rule may not seed itself enabled, and the block-shape check below — which was verified by breaking it. ### Three claims the re-read found wrong, corrected here rather than shipped 1. **Core validates `subjectKey`** against the declared variables and refuses the module. The draft taught a cooldown silently keyed on `undefined` — the thing the check exists to prevent, and a consequence a reader will never actually meet. 2. **`emit` throws outside production** and only drops-and-logs inside it. Teaching the second half alone leaves a developer meeting a throw the book says cannot happen. 3. **The seeded body was malformed** — heading `level: 2` where the block registry takes `'h2'`, and no block `id`s at all. The third is the one worth keeping. `registerEngagementSeeds` checks that `blocks` is a non-empty array and **stops**; the body is validated by the block registry, which runs in the editor and the renderer. That body would have registered, seeded, and failed the first time an operator opened it. It was found by running the template's `register()` through **core's real registry at the pinned ref** — which CI does not do and cannot: the `template` job checks the version and runs the template against fakes, and a fake accepts what core refuses. So the gap is now named three times over — in the chapter, beside the code, and in the pin's own comment, which says a pin move is a run against a real core rather than an edit here — and the rule that bit has a test that fails on it (verified by breaking it). ### One check fixed `checkLinks` walked `.core/`. Bumping this pin means cloning core into that directory first — `.gitignore` reserves the name for exactly that — and the walk then reported nine broken links in someone else's README. CI never saw it: the clone happens in the `template` job and the check runs in `prose`. A failure only a person could meet, on the path this PR asks people to walk. - [x] AI-assisted: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 17:58:31 +00:00
feat(kit): the engagement contract, taught and built (cutover 5 of 7)
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 31s
a8fa524263
The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.

Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.

What chapter 2 gained, under "Telling core something happened":

  * a TRIGGER is a payload contract, not a notification stream -- the two share
    one id namespace and are constantly confused;
  * `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
    a size ladder (a `staff` ceiling does not permit `owner`);
  * an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
    on failure, and takes CONSTANT params -- the constraint worth knowing before
    you design around it;
  * templates re-ensure per seedVersion, rule groups are offered ONCE per group
    key, so a rule appended to an existing group reaches fresh installs only;
  * `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
    is the direct write, for when there is nothing for an operator to decide.

The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.

Three claims were wrong and are corrected here rather than shipped:

  * core validates `subjectKey` against the declared variables and refuses the
    module; the draft taught a cooldown keyed on `undefined`, which the check
    exists to prevent and a reader will never see.
  * `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
    the second half alone leaves a developer meeting a throw the book says
    cannot happen.
  * the seeded body itself was malformed -- heading `level: 2` where the block
    registry takes 'h2', and no block ids at all.

The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.

Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-09-01 18:03:48 +00:00
whitlocktech merged commit e9ca759227 into main 2026-09-01 18:03:53 +00:00
whitlocktech deleted branch feat/engagement-contract 2026-09-01 18:03:54 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

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