docs(website): per-channel notification preferences (engagement Phase 3)

The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and
`android/PLAN.md` §11 — plus the phase's as-built section and one correction the
build forced.

`ENGAGEMENT.md`:
- Phase 3's as-built: the three decisions settled before any code, why the
  channel registry could not wait for Phase 6, what the sparse PUT buys, the
  projection stated as an invariant, and the staff-ceiling filter.
- **The phase's own acceptance line was wrong and is struck through.** "A fresh
  user's … push defaults `instant`" reads naturally beside §3.1's "email opt-IN,
  push opt-OUT", but §3.1 borrowed that from `team_notification_prefs`, where no
  row genuinely does mean notified. Push STREAM subscriptions have never worked
  that way — `notification_subscriptions` holds a row only on opt-in — so
  `instant` would have projected the entire catalog into the legacy GET for
  every existing user. §3.1's comment is corrected in the same pass.

`BACKEND_DESIGN.md`: the `/me/notifications/channels` row, the
`notification_channel_prefs` table entry (including that absence means the
channel's default rather than `off`, and that all three agreeing on `off` today
is a fact about the declarations and not about the table), and a note on
`notification_subscriptions` that it is now the push projection.

`android/PLAN.md` §11: nothing above it changed — the shipped APK keeps working
and the `{"streams":[]}` gotcha still applies to that endpoint. The new section
documents the superset endpoint for whenever the app adopts it: render toggles
from each item's `channels` rather than a hardcoded three, `modes` is the
effective mode and the client must not re-implement the defaulting, the PUT is
sparse so the empty-array gotcha does NOT apply here, and one id may be missing
that the app expects (a `staff`-ceilinged trigger is not offered to a non-staff
caller).

Companion to website#169.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 07:09:16 -05:00
parent 6c19a0608a
commit 7b7a26f0ac
3 changed files with 180 additions and 11 deletions

View File

@@ -1,13 +1,13 @@
# The Engagement System — findings and plan
**Status:** design of record. **Phases 1, 1a, 1b and 2 are built** (Phase 1: website#165 + docs#178,
with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b: website#167 +
docs#180); everything from Phase 3 on is still design. The scope decisions below are settled; **six of
the eight questions in §7.1 are answered** — Q1, Q3, Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at
the start of Phase 2, which also settled §7.2's namespace question. Q1's answer added a whole phase
(**Phase 1b**, unique email addresses). **Q2, Q4 and Q8** remain open and block Phases 4, 5b and 8
respectively. Per CLAUDE.md § Conventions, no implementation starts without the org lead's approval of
the phase it belongs to.
**Status:** design of record. **Phases 1, 1a, 1b, 2 and 3 are built** (Phase 1: website#165 +
docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b:
website#167 + docs#180; Phase 2: website#168 + docs#181); everything from Phase 4 on is still design.
The scope decisions below are settled; **six of the eight questions in §7.1 are answered** — Q1, Q3,
Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at the start of Phase 2, which also settled §7.2's
namespace question. Q1's answer added a whole phase (**Phase 1b**, unique email addresses). **Q2, Q4
and Q8** remain open and block Phases 4, 5b and 8 respectively. Per CLAUDE.md § Conventions, no
implementation starts without the org lead's approval of the phase it belongs to.
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
(Phase 13). §6.0a records the blocking precondition — six `edge` branches are stale and two repos have
@@ -551,7 +551,8 @@ registerDeliveryChannel({
id: 'email', // 'email' | 'push' | 'inapp' | later 'discord.dm'
label: 'Email',
carriesContent: true, // false for push — enforces the tickle invariant structurally
defaultMode: 'off', // email opt-IN, push opt-OUT — G9, expressed here once
defaultMode: 'off', // G9, expressed here once. All three are opt-IN as built —
// 'push opt-OUT' was wrong; see Phase 3's as-built
supportsDigest: true, // in-app and push are instant-only in v1
addressFor(userId), // → [{ address, meta }] ; email reads users.email, push reads push_devices
render(template, vars, ctx), // → the channel's own payload shape
@@ -1650,7 +1651,7 @@ engine, and this is what it migrates onto.
---
### Phase 3 — Channel preferences
### Phase 3 — Channel preferences
`notification_channel_prefs` + the idempotent backfill from `notification_subscriptions`. New
`GET·PUT /auth/me/notifications/channels`. **`/auth/me/notifications/subscriptions` keeps its exact wire
@@ -1658,11 +1659,87 @@ shape** and becomes the push projection — writes fan out to both.
**Acceptance:** the shipped Android app's flat `{streams:[…]}` PUT still round-trips, including the
empty-array case the app's DTO comment warns about; a per-channel PUT sets `email` without touching
`push`; a fresh user's email mode defaults `off` and push defaults `instant` 4.5's `defaultMode`).
`push`; a fresh user's email mode defaults `off` and ~~push defaults `instant`~~ **push defaults
`off` too** 4.5's `defaultMode` the struck text was wrong; see the as-built below).
**Guardrails:** swagger + route manifest; a test pinning the legacy wire shape byte-for-byte.
---
#### As built (2026-08-29)
**Three decisions were settled by the org lead before any code, and one of them corrects this
phase's own acceptance criterion.**
| | Question | Decision |
|---|---|---|
| | how much of §3.1's `registerDeliveryChannel` lands now | **the declarative half only** id, label, `carriesContent`, `defaultMode`, `supportsDigest`. `addressFor` / `render` / `deliver` wait for the phases that can exercise them |
| | push's `defaultMode` | **`off`.** The acceptance line below said `instant`; it could not be |
| | whole-set PUT or sparse | **sparse**, on the `(id, channel)` pair deliberately unlike the two whole-set PUTs either side of it |
**The acceptance line was wrong, and it is worth saying exactly how.** "A fresh user's email mode
defaults `off` and push defaults `instant`" reads naturally beside §3.1's "email opt-IN, push
opt-OUT", and §3.1 got that from `team_notification_prefs`, where no row genuinely does mean notified.
But push **stream subscriptions** have never worked that way: `notification_subscriptions` holds a row
only when a user opted in, so no row means not subscribed. A `defaultMode` of `instant` would have
projected the **entire catalog** into `GET /auth/me/notifications/subscriptions` for every existing
user, and the shipped Android client would have shown every toggle switched on after an upgrade
nobody asked for. It is a live behaviour change dressed as a default. All three channels declare
`off`, and a test asserts the legacy GET returns `{streams:[]}` for a fresh user so it cannot drift
back.
**Why the channel registry could not wait for Phase 6.** §3.1 says `defaultMode` is expressed once,
and reading a preference means knowing it a row exists only where a user has said something. The
alternative was a constant list beside the prefs model, i.e. that expression in a second place, two
phases before the registry replaced it. What did *not* land is the behavioural half: registering a
`deliver` nothing calls freezes a signature before anything has tried to use it, which is the reason
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
`inapp` is declared `off` for a reason particular to it the inbox does not exist until Phase 7, and
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
Phase 7 shipped would be a backlog.
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** A
whole-set body forces a client that only manages email to send every push row back or wipe them. And
`off` becomes a mode rather than an omission which means this endpoint has **no empty-array case at
all**, so the kotlinx gotcha `putTeamPrefs` had to document (a defaulted array field is dropped from
the body, and "clear the last one" arrives as no array) simply cannot arise here. `prefs` is still
required, so a request DTO with no default is still the right shape on the app side.
**The projection, stated as an invariant.** `notification_subscriptions` stays exactly what
`utils/pushDispatch` reads, so this phase touches no delivery path at all. Both endpoints maintain:
**a `push` pref with `mode <> 'off'` ⟺ a `notification_subscriptions` row** the legacy PUT with a
whole-set sweep, the channels PUT one pair at a time. An explicit `off` is *stored* rather than
deleted, because folding "I turned this off" back into "I never said" is only harmless while the
default happens to be off.
**One thing landed that the phase did not name, and it is a G24 consequence rather than scope creep.**
A trigger whose ceiling is `staff` can never reach a non-staff user, so offering them a toggle is
offering a control that does nothing *and* disclosing that the event exists `uo.cheat.detected`
would otherwise appear by name in every player's preferences screen the moment Phase 11 declared it.
It is filtered from the catalog and gated on write, not merely hidden. `members` is deliberately not
filtered: membership is a runtime resolver's answer, and a preference set before joining a Team should
already be in place when you join. This gave `ceilings.js` its first consumer for the `staff` label's
long-standing claim of "admin / editor / moderator", now written down as `STAFF_CEILING_ROLES` and
deliberately **not** `teamGrants.STAFF_ROLES` (`['admin','moderator']`), which answers the different
question of who may act on a Team they are not in.
**What landed:**
- `server/src/engagement/channels.js` `registerDeliveryChannel`, `MODES`, `defaultMode`, `modesFor`,
`acceptsMode`; `coreChannels.js` declares push / email / inapp, registered through the subsystem's
one door (`require('./engagement')` from `app.js`, beside `registerCore()`)
- `notification_channel_prefs` + the replay-safe `INSERT IGNORE … SELECT` backfill, copying the
`announce_jobs → announce_job_legs` precedent
- `model/notificationChannelPrefs/` the catalog union, effective-mode resolution, the sparse apply,
and `mirrorPushSet` for the legacy path; two single-row helpers on `notificationSubs.db`
- `GET · PUT /auth/me/notifications/channels`, swagger schemas, route manifest
- `ceilings.STAFF_CEILING_ROLES` / `isStaffRole`
**Left for later, deliberately:** no web or app surface. The endpoint exists and is documented
(`../android/PLAN.md` §11); the screens are Phase 7 (web) and Phase 8 (app), which is where a user can
see something a preference actually governs.
---
### Phase 4 — The engine: rules, cooldowns, outbox
`engagement_rules`, `engagement_cooldowns`, `engagement_outbox`, `engagement_sends`, the sweep worker