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

@@ -130,3 +130,163 @@ test('the manifest declares what the loader requires', () => {
// serves, so an entry in the module root would publish the whole module.
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
})
// ── The engagement seam ───────────────────────────────────────────────────
//
// Core validates most of what is declared here at registration, and a module
// that gets it wrong does not load. These tests are mostly NOT that validator
// restated: they are the rules a module can satisfy at boot and still have got
// WRONG in a way whose only symptom is mail somebody received. Where one does
// overlap core — the namespacing and subjectKey assertions below — it is because
// `npm test` is a cheaper place to meet the failure than a first boot, and the
// message here names the field.
test('every declared trigger is namespaced, ceilinged, and carries examples', () => {
const { api } = register()
const triggers = api.record.triggers
assert.ok(Array.isArray(triggers) && triggers.length, 'no triggers were declared')
for (const t of triggers) {
// Trigger ids and notification-stream ids are ONE namespace, so an id must
// carry this module's own prefix or it is a claim on somebody else's.
assert.ok(t.id.startsWith(`${manifest.id}.`), `${t.id} is not namespaced`)
// `ceiling` is required and has no default: there is no safe value to guess.
assert.ok(t.ceiling, `${t.id} declares no ceiling`)
// Core refuses this one too; failing it here just costs less. What the rule
// protects is the cooldown key — "once per world", not "once per user" — and
// a subjectKey naming nothing would key every subject on `undefined`.
const names = t.variables.map((v) => v.name)
assert.ok(names.includes(t.subjectKey), `${t.id}: subjectKey "${t.subjectKey}" is not a variable`)
for (const v of t.variables) {
// Not decoration: the example is what makes a template previewable and
// test-sendable without waiting for a real game event.
assert.ok('example' in v, `${t.id}.${v.name} has no example`)
// The type set is closed. A payload that needs a structure has outgrown
// interpolation, and a template cannot walk one.
assert.ok(
['string', 'int', 'float', 'boolean', 'datetime', 'url'].includes(v.type),
`${t.id}.${v.name} has type "${v.type}"`,
)
// A url is site-relative, because it ends up in an href in a mail somebody
// opens days later.
if (v.type === 'url') assert.match(v.example, /^\/[^/]/, `${t.id}.${v.name} must be site-relative`)
}
}
})
test('an audience resolves to nobody rather than to everybody when it fails', async () => {
const ctx = fakeCtx({ db: { query: () => Promise.reject(new Error('database is down')), pool: {} } })
const { api } = register(ctx)
const audience = api.record.audiences[0]
// The one behaviour worth a test of its own. Core treats a throw the same way,
// so this is not core's guard restated — it is the module choosing the same
// answer deliberately, and the reason is that the alternatives are both worse:
// "everyone" mails the wrong people and a stale answer mails yesterday's.
assert.deepStrictEqual(await audience.resolve({ clanId: 'clan-1' }), [])
})
test('a seeded rule names only this modules triggers, and its own or cores templates', () => {
const { api } = register()
const seeds = api.record.engagementSeeds
const ownKeys = new Set(seeds.templates.map((t) => t.key))
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
for (const t of seeds.templates) {
// The key column is UNIQUE across the whole table, so an unprefixed
// `notify.event` from a module would collide with core's body and win.
assert.ok(t.key.startsWith(`${manifest.id}.`), `template ${t.key} is not namespaced`)
// `protected` means "the system breaks without this body" — true of a
// password reset and of nothing a module ships. Core refuses a module
// template that sets it, because it would take an operator's delete button
// away.
assert.ok(!('protected' in t), `template ${t.key} may not mark itself protected`)
}
for (const group of seeds.ruleGroups) {
for (const rule of group.rules) {
assert.ok(
api.record.triggers.some((t) => t.id === rule.trigger_id),
`${rule.name} names a trigger this module does not declare`,
)
for (const key of Object.values(rule.template_keys)) {
assert.ok(ownKeys.has(key) || coreKeys.has(key), `${rule.name} names an unknown template ${key}`)
}
// `enabled` is not a parameter, and a value passed for it is ignored
// rather than refused. Passing one anyway states an intention the platform
// will not honour, so the honest thing is not to write it.
assert.ok(!('enabled' in rule), `${rule.name} may not seed itself enabled`)
}
}
})
test('every seeded body is a shape the block registry will accept', () => {
const { api } = register()
// The gap this exists for: `registerEngagementSeeds` checks that `blocks` is a
// non-empty array and stops. The BODY is validated by core's block registry,
// which runs in the template editor and in the renderer — so a malformed block
// registers, seeds, and first shows itself when an operator opens the body or a
// rule fires. Core is not here to ask, so assert the two rules that are easy to
// get wrong and impossible to notice.
const HEADING_LEVELS = ['h1', 'h2', 'h3']
for (const t of api.record.engagementSeeds.templates) {
const ids = new Set()
for (const block of t.blocks) {
// Every block carries its own id, unique within the body: it is how the
// editor addresses one block, and how `inapp.event`'s renderer maps blocks
// onto the inbox row's columns by role.
assert.ok(block.id && typeof block.id === 'string', `${t.key}: a block has no id`)
assert.ok(!ids.has(block.id), `${t.key}: two blocks share the id "${block.id}"`)
ids.add(block.id)
assert.ok(block.type.startsWith('email.'), `${t.key}: ${block.type} is not an email block`)
// A heading's `level` is a SIZE token, not a number. `{ level: 2 }` reads
// perfectly and is refused, and it renders at the default size in any
// preview that skips validation — which is the whole trap.
if (block.type === 'email.heading') {
assert.ok(
HEADING_LEVELS.includes(block.props.level),
`${t.key}: heading level "${block.props.level}" must be one of ${HEADING_LEVELS.join(', ')}`,
)
}
}
}
})
test('the world event fires on the transition and not on the poll', async () => {
const boot = require('../boot')
// Online already, and reporting online again. The refresh writes, and nothing
// is announced: this runs every thirty seconds, and a rule on an event fired
// every thirty seconds mails somebody every thirty seconds. Core's cooldown
// would hold — but leaning on it means emitting "still up" and calling it news.
const steady = fakeCtx({ db: { query: () => Promise.resolve([{ online: 1 }]), pool: {} } })
require('../core')._reset()
require('../core').init(steady)
await boot.refresh()
assert.deepStrictEqual(steady.events.emit.calls, [])
// Offline before, online now. One emit, with the declared payload.
const flipped = fakeCtx({ db: { query: () => Promise.resolve([{ online: 0 }]), pool: {} } })
require('../core')._reset()
require('../core').init(flipped)
await boot.refresh()
assert.strictEqual(flipped.events.emit.calls.length, 1)
const [triggerId, envelope] = flipped.events.emit.calls[0]
assert.strictEqual(triggerId, 'examplegame.world.status_changed')
assert.strictEqual(envelope.data.status, 'online')
// A fresh install, where there is no previous row at all. Not a change — and
// announcing it would tell everyone the world came online the first time an
// operator started the site.
const fresh = fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })
require('../core')._reset()
require('../core').init(fresh)
await boot.refresh()
assert.deepStrictEqual(fresh.events.emit.calls, [])
})