docs(engagement): the two walk defects, fixed -- and what digest is

Follows docs#203, which recorded the two defects Phase 13's acceptance walk found
and left them for placing. They are fixed in website#181, so Phase 13's as-built
says so and says what the fix was rather than leaving a reader to find the PR.

Two other files were describing a contract the code did not honour, which is why
the second defect could exist at all:

  * BACKEND_DESIGN's `template_keys` column row said only that keys are
    shape-checked. It now says the one thing that was actually load-bearing --
    `digest` is a key that is NOT a channel, because it names a MODE's body --
    and that the rules validator rejected it until website#181, which made every
    rule shipping one unsaveable from the Rules screen, core's own included.
  * MODULE_API 2.4 already told modules they may point `template_keys` at
    `notify.digest` and never said what `digest` was. A module author reading it
    would reasonably have expected it to be a channel. One bullet, next to the
    generic-bodies one that sends them there.

No contract changed; the docs were right and the code was wrong. No
MODULE_API_VERSION bump for the same reason.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 14:31:00 -05:00
parent 924204f8ba
commit b3dd2c7cbb
3 changed files with 13 additions and 3 deletions

View File

@@ -508,7 +508,7 @@ is only harmless while the default is off. Existing subscriptions are carried ac
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below | | 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 | | 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 | | 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 | | template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5. Every key is one of the rule's `channels` **plus one that is not a channel at all**: `digest` names the body the digest worker renders for a rule whose email channel an individual set to digest mode, so it belongs to a mode and can never appear in `channels`. The rules validator rejected it until website#181, which made every rule shipping one — core's own Team and news rules included — unsaveable from the Rules screen |
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables | | 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 | | cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) | | delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |

View File

@@ -3852,8 +3852,15 @@ is the Phase 11b defect class staying fixed.
key they never typed. The only way to save is to delete the digest body, which silently removes key they never typed. The only way to save is to delete the digest body, which silently removes
digest support from that rule. The two validators have to agree about what `digest` is. digest support from that rule. The two validators have to agree about what `digest` is.
Neither is fixed here: this phase's PR is the documentation of a cutover, and a core change belongs **Both are fixed in website#181.** `settings.db` gained `claim(key, value)` — the same
in its own. Both are recorded for the org lead to place. `INSERT IGNORE` as `seedDefault`, reporting its own `affectedRows`, so exactly one caller can win a
key — and both seeders now claim before inserting rather than stamping after. The trade the code
already documented is unchanged, only its order: a process that dies mid-loop leaves the group
stamped and partly seeded, which both functions already called the better failure. The stubs are
`claim`-shaped now and each suite gained a test that runs two seeders under `Promise.all`; reverting
the fix makes the module one report every rule inserted twice, which is how the fix was checked. And
`engagementRules.model.js` now admits `digest` as the one key that is a slot rather than a channel,
so the two validators agree; anything else is still refused, with a test in each direction.
**Also still open:** nothing else. `.profile/README.md` (step 8) landed — the org lead called the **Also still open:** nothing else. `.profile/README.md` (step 8) landed — the org lead called the
engagement system a headline capability — and Phase 12's second finding became **Phase 14**. engagement system a headline capability — and Phase 12's second finding became **Phase 14**.

View File

@@ -856,6 +856,9 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio
`notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a `notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a
bespoke body when the message has something to say that the structural projection cannot; a trigger bespoke body when the message has something to say that the structural projection cannot; a trigger
whose message is "this happened, here is the link" should not have one. whose message is "this happened, here is the link" should not have one.
- **`digest` is a slot, not a channel.** It names the body the digest worker renders for a rule whose
email channel an individual set to digest mode, so it is legal in `template_keys` and never appears
in `channels`. Every other key must be one of the rule's channels.
- **It is not a send path.** Every value on the object is data. Core still decides who is told. - **It is not a send path.** Every value on the object is data. Core still decides who is told.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5. **`onBoot(fn)` / `onShutdown(fn)`** — §2.5.