feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m37s

Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction.
This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger
now produces outbox rows and send-log entries; Admin - Engagement - Rules and the
segment composition UI are 4b.

Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep
worker, audience resolution, condition evaluation, the grace window and its
cancellation, and the save-path validation 4b's form will call. engagementEmit's
Phase 2 log line becomes the engine call.

Two settled questions this phase was blocked on:

  Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the
  outbox claims each row with a compare-and-set into the 'sending' state the ENUM
  already carried. It makes the outbox safe for two instances, not the deployment.

  Q4 (admin surface) - its own top-level nav group, built in 4b.

Two defects in the plan's own section 4, both found by building it:

  The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and
  one event is one row per (rule, user, channel) - so a fifty-person audience
  would have had one row admitted and forty-nine silently ignored. Scoped.

  Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always
  passes against this codebase's pool: the mariadb connector defaults
  foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is
  two statements now, with the interval guard in a WHERE clause.

The second defect is why there is a second test file. The stubbed suite was green
against the broken claim, because a stub can only agree with whoever wrote it;
engagementEngineSql.test.js runs the raw statements against a real MariaDB and
skips when there is none.

Verification: 43 new tests green in engagementEngine.test.js, 12 more against
MariaDB 11.8, and the whole path exercised end to end against a live database -
per-subject cooldowns, conditions, the CAS claim, the send log's honest failure
detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF
failures in the generated-artifact tests are unchanged from clean edge.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 08:07:27 -05:00
parent 447c9113d3
commit 2079aaf667
18 changed files with 3322 additions and 11 deletions

View File

@@ -1,16 +1,17 @@
// ── ctx.events.emit — the validating half of the engagement seam ────────────
//
// ENGAGEMENT.md §4.3 and §5.2, Phase 2. A registrant fires a declared event with
// a payload; this checks the payload against the declaration and stops there.
// **There is no delivery in this phase** — no rules, no cooldowns, no outbox, no
// mail. Phase 4 replaces the log line at the bottom with the engine call, and
// every validation rule below is already the one it will need.
// ENGAGEMENT.md §4.3 and §5.2. A registrant fires a declared event with a
// payload; this checks the payload against the declaration and, since Phase 4a,
// hands the validated event to the engine.
//
// Landing the contract a phase before the engine is deliberate, and it is the
// Landing the contract a phase before the engine was deliberate, and it is the
// same argument registerCore() has always made: a seam whose first real exercise
// is the thing that depends on it is a seam that has already drifted. Phase 6
// migrates the Team mail onto this, and it should be migrating onto a validator
// that has been running against core's own five triggers since Phase 2.
// is the thing that depends on it is a seam that has already drifted. Every
// validation rule below was written in Phase 2 for a caller that did not exist
// yet, and the engine needed none of them changed.
//
// **The engine call is deliberately not awaited** — see `emit` below. Phase 6
// migrates the Team mail onto this.
//
// **Two postures, one switch.** A malformed emit THROWS in development and is
// DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture
@@ -20,6 +21,7 @@
// silently loses a variable is a template that silently renders `undefined`.
const registries = require('../modules/registries')
const engine = require('../engagement/engine')
const createLogger = require('./logger')
const log = createLogger('engagement')
@@ -203,8 +205,6 @@ function emit(owner, triggerId, envelope = {}) {
data: payload.data,
}
// Phase 2 ends here: validated, recorded, and deliberately undelivered.
//
// The values are NOT logged. A payload carries player names, house locations
// and forum excerpts, and an event log that reproduces them is a second copy
// of exactly the content §4.5 was careful to keep out of `engagement_sends`
@@ -217,6 +217,19 @@ function emit(owner, triggerId, envelope = {}) {
variables: Object.keys(event.data),
})
// **Not awaited, and this is the point of the whole seam.** `emit` is called
// from inside a game-event handler; the caller's job is to say the event
// happened, and it must not be made to wait on rule lookups, audience
// resolution and a dozen inserts to find out whether it is allowed to carry on.
// That is the same reason the C# side's `Emit()` enqueues and returns rather
// than touching the socket from the Core thread. `dispatch` catches everything
// internally and never rejects, and the `.catch` is the belt to that braces.
//
// The consequence a test has to know about: `emit` returns before the outbox
// rows exist. `engine.dispatch(event)` is exported for a caller that needs to
// await the delivery decision, and the tests use it directly.
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
return { ok: true, event }
}