You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make the entire notifications story reasonable from one place. Route every notification trigger through a single emit(event_type, …) dispatcher, back it with a queryable per-recipient Notification record, and give system happenings a home in the timeline via a new GameEvent primitive. This is the structural fix for the recent wave of notification bugs (#1032–#1035) and the base the reliability epic's later commitment work builds on.
What changed from the original plan
The original plan consolidated every trigger into notification/signals.py using transition decorators. Building it (#1072 / PR #1079) showed the signal approach is the wrong shape: several real triggers — GM game-delete, deadline extended, NMR extensions applied, staging kicks — are not model-field transitions, so they needed a second synthetic-signal mechanism bolted alongside the decorators, recreating the half-imperative / half-signal split this epic set out to remove.
Revised approach: abandon notification signals entirely. Centralize the notification machinery (recipient rules, copy, per-recipient records, delivery) behind a single emit(...) dispatcher and call it explicitly from each domain action site. This keeps "one place to look" without contorting real triggers into field-diff detection — the scattering that was the original problem was of the logic, not of a one-line declaration at the site where the thing happens.
emit(event_type, …) — one dispatcher. Looks up the event type's classification (timeline-only / push+timeline / push-only) and its recipient resolver, then fans out to the sibling primitives below. The classification table is the reliability doc's matrix, in code.
Notification.objects.broadcast(...) — writes one durable per-recipient record inside the caller's transaction, then defers batched FCM delivery and writes status back. Source of truth for "did we notify, whom, did it land."
GameEvent — system-generated timeline entry (channel FK, nullable phase FK, no sender). A plain DB row read on fetch (no realtime layer). Orthogonal to pushing: a happening may create a chip, a push, both, or neither.
Notification and GameEvent stay decoupled siblings — emit fans out to each per the classification. Neither drives the other: their audiences, copy, and failure modes differ (e.g. game_deleted pushes with no chip; civil_disorder gets a chip with no push; an elimination chip is game-wide but the push goes only to the eliminated player).
Convention (to document in CLAUDE.md / backend skill)
No notification trigger lives in a signal. Every trigger is an explicit emit(...) at the domain action site.
Recipient rules and copy live only in emit's resolvers — never re-implemented per call site.
Time-based notifications are armed via schedule_at and re-evaluated at fire time, not swept.
Plan
Behaviour is preserved through PR 8; the hygiene re-bucketing (e.g. civil disorder stops pushing) only lands with the GameEvent model + chip in PR 9–11, because a type can only lose its push once its chip renders.
Goal
Make the entire notifications story reasonable from one place. Route every notification trigger through a single
emit(event_type, …)dispatcher, back it with a queryable per-recipientNotificationrecord, and give system happenings a home in the timeline via a newGameEventprimitive. This is the structural fix for the recent wave of notification bugs (#1032–#1035) and the base the reliability epic's later commitment work builds on.What changed from the original plan
The original plan consolidated every trigger into
notification/signals.pyusing transition decorators. Building it (#1072 / PR #1079) showed the signal approach is the wrong shape: several real triggers — GM game-delete, deadline extended, NMR extensions applied, staging kicks — are not model-field transitions, so they needed a second synthetic-signal mechanism bolted alongside the decorators, recreating the half-imperative / half-signal split this epic set out to remove.Revised approach: abandon notification signals entirely. Centralize the notification machinery (recipient rules, copy, per-recipient records, delivery) behind a single
emit(...)dispatcher and call it explicitly from each domain action site. This keeps "one place to look" without contorting real triggers into field-diff detection — the scattering that was the original problem was of the logic, not of a one-line declaration at the site where the thing happens.emitteardown in PR 4.Architecture
emit(event_type, …)— one dispatcher. Looks up the event type's classification (timeline-only/push+timeline/push-only) and its recipient resolver, then fans out to the sibling primitives below. The classification table is the reliability doc's matrix, in code.Notification.objects.broadcast(...)— writes one durable per-recipient record inside the caller's transaction, then defers batched FCM delivery and writes status back. Source of truth for "did we notify, whom, did it land."GameEvent— system-generated timeline entry (channelFK, nullablephaseFK, no sender). A plain DB row read on fetch (no realtime layer). Orthogonal to pushing: a happening may create a chip, a push, both, or neither.NotificationandGameEventstay decoupled siblings —emitfans out to each per the classification. Neither drives the other: their audiences, copy, and failure modes differ (e.g.game_deletedpushes with no chip;civil_disordergets a chip with no push; an elimination chip is game-wide but the push goes only to the eliminated player).Convention (to document in CLAUDE.md / backend skill)
emit(...)at the domain action site.emit's resolvers — never re-implemented per call site.schedule_atand re-evaluated at fire time, not swept.Plan
Behaviour is preserved through PR 8; the hygiene re-bucketing (e.g. civil disorder stops pushing) only lands with the
GameEventmodel + chip in PR 9–11, because a type can only lose its push once its chip renders.2Colocate the elimination + civil-disorder write sites— closed; folded into PR 4emitdispatcher + classification table + recipient resolvers, wrapping the existing send pathemit(incl. colocating the elimination/CD writes); deletesignals.pyreceivers + the #1071 decorator infraemitat fire timebroadcast()delivery; swapemit's internals; deletesend_notificationactive_only, delete the reconcile pass, keep guaranteesGameEventmodel + merged/paginated timeline APIGameEventtimeline chip (frontend)GameEvents + apply notification hygiene (re-bucketing)Decisions
emit(...)calls, not signals — see "What changed" above.emitexisted, it produced only a cosmetic wrapper.)removed_from_stagingmoves from one grouped message to one-per-game (PR 4).GameEventchip (PR 9–11);emitis behaviour-preserving until then.