feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a) #170
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-engine"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 4, split 4a / 4b at the org lead's direction. Companion docs PR: docs#183.
This is 4a: the engine, server only, with no HTTP surface at all. It is provably done when a fired trigger produces an outbox row and a send-log entry with no UI in the picture. Admin → Engagement → Rules and the §5.1a segment composition UI are 4b.
Three decisions settled before any code
SKIP LOCKED, or document single-instance?sendingstate §4.2a's ENUM already carriedQ2's answer makes the outbox safe for two app instances. It does not make the deployment multi-instance —
announceWorker,teamDigestWorker,teamForumUploadSweepandteamActivityPruneare all still written for one, and widening them is not this phase's scope. What it buys is that the one table that will carry mail is ready for the day it is.What lands
Five tables (
engagement_rules,engagement_audience_segments,engagement_cooldowns,engagement_outbox,engagement_sends),src/engagement/{engine,conditions,segments,audiences}.js,src/model/engagement/*, andutils/engagementWorker.jswired intoserver.jsbeside its five siblings.engagementEmit's Phase 2 log line becomes the engine call.The gate order is the design, and two placements are load-bearing:
ceilings.permits, so the only way this can fail is the case it exists for — a module upgrade that narrows its trigger's declaration underneath a rule saved when it was wider. Without it, a rule written against yesterday's declaration keeps reaching yesterday's population forever. This is the second call site §5.1a promised, not a second implementation.Two defects in ENGAGEMENT.md's own §4, both found by building it
1. §4.2a's global
UNIQUE (dedupe_key)was data loss, not a style question. A dedupe key names the event — "house 0x4001 entered IDOC" — and one event legitimately becomes many rows: fifty recipients is fifty rows, a rule spanning email and in-app doubles that, a second rule doubles it again. Under a global unique index the first insert wins and every other one is silently ignored: ninety-nine recipients dropped by the mechanism meant to stop a replay becoming a second mail. Shipped asUNIQUE (rule_id, user_id, channel, dedupe_key).2. §4.1's single
INSERT … ON DUPLICATE KEY UPDATEcooldown claim always passes against this codebase's pool. Its answer is read out ofaffectedRows— 1 inserted, 2 updated-and-changed, 0 for a duplicate key whose update changed nothing, that 0 being "still cooling". The mariadb connector defaultsfoundRows: true, which makesaffectedRowsreport rows matched rather than changed, andutils/db.jsdoes not override it. The no-op returns 1, indistinguishable from a fresh insert. Every cooldown passes, always. It is two statements now — a guardedUPDATEwith the interval in a WHERE clause, falling back toINSERT IGNOREfor a first fire — still race-free in both directions.The second defect is why this PR has two test files.
engagementEngine.test.jsstubs the five tables and was green against the broken claim, because a stub can only agree with whoever wrote it and the same misreading produced both.engagementEngineSql.test.jsruns the raw statements against a real MariaDB, creates a throwaway database and drops it, and skips when there is none so CI stays green without one. A stub is a fine stand-in for a table and a poor one for a protocol.Segments — one rule §5.1a did not state
notis legal only as a child ofand. A complement needs a universe, and the only one that does not widen is the set its siblings produced:A AND NOT Bis "A, less B". A bareNOT B— orA OR NOT B— would have to mean "everyone except…", a broadcast built out of one narrow audience, which is exactly the widening rule 3 forbids. Refused at save, in that sentence.And the other half: a
notcontributes no ceiling to the meet. Excluding people cannot widen who an expression reaches, so folding the excluded audience's ceiling in would refuse safe segments —members AND NOT staffwould hitmeet('members','staff') = nulland be rejected despite reaching strictly fewer people thanmembersalone.Dormancy, three ways, and none of them deletes anything
A rule naming an unregistered trigger, a rule whose channel is gone, and a rule whose segment was deleted are all listed, flagged and left alone (§7.3). In particular
audience_segment_iddeliberately carries no foreign key:CASCADEwould delete an operator's rules andSET NULLwould silently fall the rule back to its plainaudiencecolumn — and that fallback reaches a different set of people, the exact failure §5.1a rule 4 exists to prevent. Deleting a segment a rule still uses is refused in the model, with the count.Nothing is delivered, and that is visible rather than pretended
A channel's
deliverarrives with email in Phase 6 and the inbox in Phase 7. Until then the worker claims the row, finds nodeliver, finishes itfailed, and the send log says so in as many words. Recordingsentwould be a lie in the one table whose purpose is answering "did they get it"; leaving itscheduledwould mean an IDOC warning queued today arriving three weeks later on the deploy that first ships a mailer. On a real deployment the path is unreachable anyway — core seeds no rules andenableddefaults to 0, so nothing enqueues until 4b's screen exists and an operator uses it.Verification
npm test— 43 new tests intest/engagementEngine.test.js, all green. The three failures in the suite (routes.manifest,routes.guards,the committed manifest) are the known Windows-only CRLF artifacts and fail identically on a cleanedge— verified withgit stash -uin the same session.test/engagementEngineSql.test.js), including one that pins thefoundRowsbehaviour that caused defect 2 so it cannot come back.schema.sqlapplied and replayed against a throwaway database — idempotent, anduq_engo_dedupeverified four-column on the server.No routes, so no swagger or route-manifest regeneration.
🤖 Generated with Claude Code