docs(engagement): Phase 11b's live walk — three decisions and six defects

The walk is the last piece of 11b and it was not a formality. It found six
defects, four of them in code shipped by earlier phases, and none of the six was
visible in a unit test: each is a disagreement between two things that agree with
each other in a fixture.

Three of the fixes were decisions rather than repairs, all settled by the org
lead before any code:

  11  `uo.house.idoc_warning` ships delay_seconds: 900 and nothing could cancel
      it  ->  add `uo.house.refreshed`, a 26th trigger with a body and a rule
  12  a rule with a cooldown delivered on exactly ONE of its channels
          ->  `channel` joins the cooldown key; a cooldown is per DELIVERY
  13  `uo.vendor.expiring` could not fire, because the market sweep does not
      diff fees  ->  widen BridgeMarket.Signature() with exempt +
      periodsRemaining

Files:

  website/ENGAGEMENT.md   the three decisions, the four repairs, and what the
                          walk proved rung by rung; the 11b bullet and the §8.6
                          family table now read 26 triggers / 34 bodies
  website/BACKEND_DESIGN.md  engagement_cooldowns gains `channel` in its PRIMARY
                          KEY, with the migration's information_schema guard and
                          why MariaDB forces one
  link/v5.md              the sweep has to DIFF the fees or the frame never
                          comes -- stated as the general rule for the next
                          enrichment, since it is emit cadence and not shape
  modules/uo/API.md       §5.7a the cancel-shaped trigger and the Ageless-vs-
                          LikeNew ServUO fact; §5.7b every link comes from
                          config/clientPaths.js, and the two mistakes that made
                          every call-to-action a dead link

Pairs with website#<core>, Module-uo#<uo> and servuo-plugins#<plugin>.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 07:13:03 -05:00
parent c9873eff7d
commit cd0d22a225
4 changed files with 203 additions and 11 deletions

View File

@@ -545,21 +545,35 @@ The ceiling is a **stored column rather than a runtime computation** so an audit
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)
### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b)
| 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 |
| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend |
| 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.
`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `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.
**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that
it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so
without the channel in the key the first channel of a rule claimed the cooldown and every later one
was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and
in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per
occasion: an operator who says "one a day about this house" means one mail and one inbox item.
Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a
key change** — replaying `schema.sql` on every boot would fail after the first run without the
`information_schema` guard that reads whether the key already carries the column. Rows written before
the migration keep `channel = ''` and expire on their own interval; dropping the table instead would
let a storm through the window.
**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