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

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>
This commit is contained in:
2026-09-01 12:57:55 -05:00
parent 39736f8448
commit a8fa524263
12 changed files with 597 additions and 27 deletions

View File

@@ -147,8 +147,9 @@ statement of your dependencies, and it makes a test double for it — see
## What you register
Eight calls, all synchronous, all documented in [§2.4][api]. What is worth knowing
is not their signatures but the model behind them.
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
model behind them.
**Every call stages; nothing is committed until your whole module is known good.**
The shape of a claim is checked at the call, so a malformed one throws with your
@@ -279,6 +280,119 @@ Every hook is awaited and none may throw past core: a subscriber's failure costs
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
somebody's blog post edit would be a worse bug than a stale mirror.
### Telling core something happened
Three registrations and one call, and together they are the seam where a module
is most tempted to reach past the boundary. The rule that keeps them safe is one
sentence: **you declare what CAN happen; core decides who is told.**
A **trigger** is not a notification stream, and the two are easy to confuse
because both are catalogs of things that happen in your game. A stream is a
subscribe toggle, and you publish to it yourself. A trigger is a **payload
contract**: it names the variables an event carries and how wide an audience it
may ever be given, an operator writes rules against it, and *core* does the
sending. Their ids share one namespace, so declaring both for the same id is
legal — that is one event with a toggle and a contract — while taking an id
another module owns is not.
Two fields on a trigger are worth more than their size.
**`ceiling` is required and has no default, and the values are ordered by
containment rather than by size.** It is the widest audience a rule on this
trigger may ever be given. There is no safe value to guess: `owner` silently
breaks a broadcast, `authenticated` silently widens something meant for staff.
And the ladder reading of the seven values is the trap — a `staff` ceiling does
**not** permit `owner`, because "one person" for a cheat-detection event is *the
player it was detected on*. Fewer people is not less exposure.
**`subjectKey` must name one of your declared variables**, because it is what the
cooldown is keyed on — "once per house", not "once per user". Core checks it at
registration and refuses the module, so this is one you meet at your first boot
rather than in production. The check is there because the failure it prevents is
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
which looks exactly like the feature working right up until two houses share it.
Every variable needs an `example`, and it is not decoration: it is what lets an
operator preview and test-send a body without waiting for a real game event,
which is the reason template systems ship untested. The type set is closed and
has no `object` or `array` — a message that has to walk a structure has outgrown
interpolation.
An **audience** is a named set of *people* you can resolve over your own data,
for an operator to point a rule at. "This clan's members" is one. "Everyone who
opened the last mail" is not, and nothing here builds it.
**Your resolver returns user ids and nothing else.** It is not handed a template,
a channel or an address, and it cannot enumerate them; core maps ids to addresses
on its own side, after preferences, suppression and the verification gate. That
narrowness is deliberate — a module still cannot send mail, and this is the
obvious place a back door would go. Two consequences follow from it:
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
its last good answer. Core enforces that, and your resolver should choose it
too, so the log can say which clan.
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
There is no way to say "the clan this event was about" — if a rule needs that,
the *event* carries its own recipients instead. This is the constraint most
worth knowing before you design around it rather than after.
The third registration ships the **content**: the bodies your messages use and
the rules that decide when one is sent. Both arrive **switched off**, and
`enabled` is not a parameter. An operator turns a module's mail on; installing a
module never does.
The two halves have different lifetimes, and the asymmetry is the contract.
**Templates re-ensure on every boot** under a seed version, so a better default
reaches deployments that never edited it while one an operator *has* edited is
left alone. **Rule groups are offered once, per named group key**, because
re-offering would resurrect a rule somebody deleted and reset one they enabled.
The consequence is easy to trip over: a rule appended to an existing group
reaches **fresh installs only**. That is the guarantee rather than a limitation
to route around, and a rule that must reach existing deployments takes a new
group key. You name the groups, so the choice is yours to make knowingly.
Core's generic bodies are a first-class answer rather than a fallback. Point a
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
own when the message has something to say that a structural projection of the
payload cannot.
**One thing in a seeded body is not checked when you register it.** The call
asserts that `blocks` is a non-empty array and stops there; the body itself is
validated by the block registry, which runs in the editor and in the renderer. So
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
Templates once, and you have checked the half that boot cannot.
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
triggers — core binds the owner from the calling module and never reads it from
the arguments, so there is no shape of this call that fires somebody else's
event. It returns nothing and, in production, never throws: there is nothing a
module could correctly do about a delivery failure from inside a game-event
handler, so there is nothing to await. **Outside production it does throw**, at
your call site — a payload that does not match the contract you declared is a bug
rather than a condition, and the throw is how you meet it in your own tests
instead of in an operator's log six weeks later.
Beside it is the one call that skips the rules entirely.
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
inbox. Reach for it when there is nothing for an operator to decide — a job that
person started has finished — and for anything else use a trigger, so the message
can be turned off, re-targeted, or sent by mail as well without a code change. The
posture is `emit`'s: the owner is bound from the calling module, it returns
nothing, and it will not tell you that the user has that channel switched off,
because a module that could see that could enumerate people's preferences one
write at a time.
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
thirty seconds and emits only when the world's online state actually changed.
Core's cooldown and hourly cap would both hold if it did not — but leaning on
them means emitting "the world is still up" and calling it news, and the operator
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
Two smaller traps sit inside the same function and are worth reading in
`template/server/boot.js`: the previous state has to be read *before* the write,
or every poll looks like no change at all, and the very first boot has no previous
state, which is not a change either.
### Becoming the source of Teams
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and