docs(website): the engagement engine as built (Phase 4a)
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.
ENGAGEMENT.md
- Phase 4 is split 4a / 4b, with what each owes.
- Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
(android CI) is open.
- The as-built: the gate order and why two of its placements are load-bearing,
the segment rule the design never stated (not is legal only inside an and,
and contributes no ceiling), dormancy three ways and why audience_segment_id
has no foreign key, the conditions grammar's two fail-closed properties, and
why emit does not await the engine.
- Section 4.1's cooldown statement and section 4.2a's dedupe index are
corrected in place, so the design sections stop teaching the two defects.
BACKEND_DESIGN.md
- Five new tables in the schema inventory, each with the reasoning a reader
would otherwise have to reconstruct: why subject_key is in the primary key,
why the dedupe index is scoped, why the send log survives an account
deletion and is not a second address book, and why a rule points at a
segment without a foreign key doing it.
No route table changes - Phase 4a adds no routes.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -491,6 +491,131 @@ is only harmless while the default is off. Existing subscriptions are carried ac
|
||||
`INSERT IGNORE … SELECT` backfill in `schema.sql`, replay-safe on every boot like the
|
||||
`announce_jobs → announce_job_legs` one it copies.
|
||||
|
||||
### engagement_rules — the operator's configuration (engagement phase 4a)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| trigger_id | VARCHAR(96) NOT NULL | a declared trigger id. **No FK and no existence check** — a trigger is declared in code, so a rule naming one no module currently registers is *dormant*, never deleted ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.3) |
|
||||
| name | VARCHAR(160) NOT NULL | |
|
||||
| enabled | TINYINT(1) NOT NULL DEFAULT **0** | off by default, so no import, seed or restore can start mailing on its own (§7.1 Q3) |
|
||||
| audience | VARCHAR(32) NOT NULL DEFAULT 'owner' | a ceiling name — `owner` / `staff` / `subscribers` / `members` / `authenticated` / `everyone` |
|
||||
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
|
||||
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
|
||||
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
|
||||
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5 |
|
||||
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
|
||||
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
|
||||
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
|
||||
| cancel_on | JSON NULL | trigger ids that cancel a pending row for the same subject |
|
||||
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
`INDEX(trigger_id, enabled)` — the engine's one indexed read per emit.
|
||||
|
||||
**`audience_segment_id` carries no foreign key on purpose.** The two options a database offers are
|
||||
both wrong here: `ON DELETE CASCADE` would delete an operator's rules, and `ON DELETE SET NULL` would
|
||||
silently fall the rule back to its plain `audience` column — and that fallback reaches a **different
|
||||
set of people**, which is the failure §5.1a rule 4 exists to prevent. A rule whose segment is gone is
|
||||
dormant and sends nothing, and deleting a segment a rule still uses is refused in the model.
|
||||
|
||||
### engagement_audience_segments — operator-composed audiences (engagement phase 4a)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| name | VARCHAR(160) NOT NULL | |
|
||||
| expression | JSON NOT NULL | a boolean tree of module-declared audience ids + params |
|
||||
| ceiling | VARCHAR(32) NOT NULL | **derived, never operator-typed** — the narrowest ceiling in the tree |
|
||||
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||
| created_at / updated_at | DATETIME | |
|
||||
|
||||
**Composition narrows, never widens.** `A OR B` takes the *tighter* of the two ceilings, not the
|
||||
looser: a ceiling states what an expression is allowed to reach, not what it will resolve to, so the
|
||||
boolean operator's direction is irrelevant. Two incomparable ceilings have no meet and the save is
|
||||
refused rather than resolved to a guess (`src/modules/ceilings.js`). `not` is legal only inside an
|
||||
`and` — a complement needs a set to be taken from, and "everyone except…" is a broadcast built out of
|
||||
a narrow audience — and it contributes no ceiling of its own, since excluding people cannot widen.
|
||||
|
||||
The ceiling is a **stored column rather than a runtime computation** so an audit can read what a rule
|
||||
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
|
||||
ceiling cannot retroactively widen a segment saved under the old one.
|
||||
|
||||
### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
|
||||
| last_fired_at | DATETIME NOT NULL | |
|
||||
| fire_count | INT NOT NULL DEFAULT 1 | |
|
||||
|
||||
`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune.
|
||||
|
||||
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
|
||||
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
|
||||
(rule, user) alone silently drops three of them.
|
||||
|
||||
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
|
||||
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
|
||||
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
|
||||
connector's default `foundRows: true` makes a no-op update report 1 rather than 0 — under which every
|
||||
cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
|
||||
|
||||
### engagement_outbox — the send queue (engagement phase 4a)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | BIGINT AUTO_INCREMENT PK | |
|
||||
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
|
||||
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | |
|
||||
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
|
||||
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
|
||||
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
|
||||
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
|
||||
| attempts / last_error / sent_at | | |
|
||||
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures |
|
||||
|
||||
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
|
||||
`INDEX(rule_id, user_id, subject_key, status)`.
|
||||
|
||||
**The unique key is scoped, and a global one would have been a data-loss bug.** A dedupe key names the
|
||||
*event*; one event legitimately becomes one row per (rule, user, channel), so a fifty-person audience
|
||||
on two channels is a hundred rows carrying the same key. A global `UNIQUE(dedupe_key)` admits the first
|
||||
and silently ignores the rest.
|
||||
|
||||
**A row is claimed with a compare-and-set** — `UPDATE … SET status='sending' WHERE id=? AND
|
||||
status='scheduled'` — and the sweeper the server reports `affectedRows = 1` to owns it (§7.1 Q2). That
|
||||
makes the outbox safe for two app instances; the other four workers in this codebase are still
|
||||
single-instance, so the deployment as a whole is not. A row stranded in `sending` by a crashed process
|
||||
is reclaimed after a window, because `status='scheduled'` would otherwise never match it again.
|
||||
|
||||
### engagement_sends — the send log (engagement phase 4a)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | BIGINT AUTO_INCREMENT PK | |
|
||||
| outbox_id | BIGINT NULL | |
|
||||
| rule_id | INT NULL | |
|
||||
| trigger_id | VARCHAR(96) NOT NULL | |
|
||||
| user_id | INT NULL FK→users(id) **ON DELETE SET NULL** | the log survives an account deletion |
|
||||
| channel / transport | VARCHAR(32) | which channel, and which mail transport actually carried it |
|
||||
| address_hash | CHAR(64) NULL | sha256 — enough to correlate a bounce (Phase 9), useless as a mailing list |
|
||||
| status | ENUM('sent','failed','suppressed','bounced','complained') | |
|
||||
| detail | VARCHAR(500) NULL | |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
`INDEX(trigger_id, created_at)`, `INDEX(user_id, created_at)`, `INDEX(rule_id, created_at)` — the last
|
||||
of those is the per-rule hourly ceiling's count, which runs once per rule per event.
|
||||
|
||||
G15: "did user X get the mail?" has never been answerable on this deployment. A row is written for
|
||||
**every terminal outcome**, not only success — "no, and here is why" is an answer this table has to be
|
||||
able to give — and the hourly ceiling counts only `sent`, so a broken transport cannot silently consume
|
||||
a rule's budget and mute it.
|
||||
|
||||
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
|
||||
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
|
||||
counts only.
|
||||
|
||||
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
||||
|
||||
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
|
||||
|
||||
Reference in New Issue
Block a user