Skip to content

Notification architecture: emit-driven, model-first redesign #1069

Description

@johnpooch

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-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.

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 (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.

PR Issue Scope Depends on
1 #1070 Remove the dead deadline-warnings endpoint
2 #1083 Colocate the elimination + civil-disorder write sites — closed; folded into PR 4
3 #1084 Add the emit dispatcher + classification table + recipient resolvers, wrapping the existing send path
4 #1085 Route all call sites through emit (incl. colocating the elimination/CD writes); delete signals.py receivers + the #1071 decorator infra #1084
5 #1073 Signal-arm the deadline warning; delete the sweep; fire emit at fire time #1084
6 #1074 Model-first broadcast() delivery; swap emit's internals; delete send_notification #1084
7 #1075 Centralize notification tests + finalize convention docs #1074
8 #1076 Re-express the #1036 CD fix: drop active_only, delete the reconcile pass, keep guarantees #1085
9 #1086 GameEvent model + merged/paginated timeline API
10 #1087 GameEvent timeline chip (frontend) #1086
11 #1088 Emit GameEvents + apply notification hygiene (re-bucketing) #1074, #1086, #1087

Decisions

  • Notification triggering is expressed as explicit emit(...) calls, not signals — see "What changed" above.
  • Elimination / civil-disorder writes are colocated as part of PR 4 (Notifications PR 4: route all triggers through emit and delete the signals #1085), keeping their bulk-write semantics; the per-instance-save conversion the original signal plan needed is dropped. (The standalone colocation PR, Notifications PR 2: colocate the elimination and civil-disorder write sites #1083, was closed — done blind before emit existed, it produced only a cosmetic wrapper.)
  • removed_from_staging moves from one grouped message to one-per-game (PR 4).
  • Hygiene re-bucketing is gated on the GameEvent chip (PR 9–11); emit is behaviour-preserving until then.
  • Deadline-warning threshold/frequency tuning stays out of scope (tracked separately).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions