ADR-0007: Recipient Fan-out V2 Ships Behind a Staged Rollout Flag
Status
Accepted
Context
Push recipients were resolved as one endpoint per user (core/device_fanout.py
pre-migration behavior). Moving to a per-install recipient set (so a member
with two phones is reached on both) changes who receives azaan for every
broadcast on the platform — a change that, if wrong, silently under- or
over-delivers a live production notification with no easy way to detect the
mistake after the fact.
Decision
Ship the new resolution logic behind a three-state environment variable,
FANOUT_V2 (off / shadow / enforce, default off), read on every
call so a flip takes effect on the next broadcast with no restart or
redeploy:
off— pre-migration behavior (one endpoint per user).shadow— delivers exactly whatoffdelivers, but additionally resolves the per-install set and records the difference on the broadcast's audit row, to gather evidence before flipping.enforce— delivers to the per-install set.
An unrecognised value logs a warning and is treated as off — a
configuration typo must not silently change who receives azaan. A second
flag, FANOUT_LEGACY_BRIDGE, keeps a member with an approved membership
but no usable per-install token resolvable from legacy users.*_token
columns during the transition.
Alternatives Considered
Not recorded in source material beyond the shadow-mode pattern itself,
which ADR-0011 notes was deliberately not reused for the device
registry unification because that cutover had no live production traffic
at risk — implying the team's default posture is to reserve staged/shadow
rollouts specifically for changes where production traffic is at risk, and
skip them otherwise.
Consequences
- The eventual
enforcestate is gated on a measurable, queryable condition (broadcast_audit_logs.fanout_lost_usersacross a 7-day window showing zero losses over sufficient measured traffic) rather than a subjective "looks fine" judgment — see Backend Deployment for the exact gate query. - Rollback is a single environment variable change with no restart, no
migration, and no data restore — rehearsed in
fastapi-backend/test/test_rollback_rehearsal.pyacross all seven send paths. - Rollback does not undo everything: a push token retired while
enforcewas active stays retired, because the provider reported it dead — a fact independent of which recipient-set mode was active. - As of this writing, the flag remains at its default (
off) in production — the decision recorded here is the rollout mechanism itself, not a claim that the fan-out has been fully enforced.