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:
2026-08-29 08:07:42 -05:00
parent 713e6fa6c8
commit deaf491dc5
2 changed files with 316 additions and 22 deletions

View File

@@ -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