All work

Activation Notification Engine — nudges that can't double-send

8 nudge types across 2 chat platforms · built from an empty directory in 7 weeks

Every notification system eventually sends something twice. The only question is whether the second one is possible or merely unlikely.

The problem

Managers who start a growth plan and then go quiet don't need a better product — they need a nudge at the right moment, on the tool they already have open. That means scheduled outbound messages, per-user opt-out, and interactive replies, across two chat platforms with completely different semantics.

And it means never, under any circumstance, sending the same nudge twice.

The approach

  • The guarantee lives in Postgres. A partial unique index over (user, type_key, occurrence_key) conditioned on the row being in a sent state makes a duplicate physically impossible, with a check constraint requiring a sent row to name the platform it went out on. No application-level "did we already send this?" read can race it.
  • Ordered gates. Resolve-once dedup, then opt-out, then a 48-hour quiet window so activation nudges never stack on one morning — with exemptions that work in both directions, so a recurring reminder is neither suppressed by the window nor holds it open forever.
  • Sent is recorded after the send, not before. A worker that dies mid-delivery retries. The opposite ordering silently burns the nudge.
  • Self-rescheduling chains. Each occurrence enqueues the next at anchor + N × interval, absolute rather than relative so the series can't drift, capped, and with liveness gated on the series being active rather than on this occurrence being eligible — otherwise one transient ineligibility kills the chain permanently.
  • Chat clients are untrusted input. Button values are JSON-encoded, validated against a server-side catalog, and return nothing rather than raising on a forged payload. Labels come from the catalog, never echoed from the client. First-answer-wins is a conditional update, not a read-then-write.

Stack

Django · PostgreSQL · Redis · RQ · Slack Bolt · Microsoft Bot Framework · Adaptive Cards · React · TypeScript

Outcomes

  • Eight distinct nudge types shipped across two chat platforms in about seven weeks, reusing each platform's existing bot endpoint so no new inbound webhook or infrastructure was needed.
  • A React preferences surface with per-type opt-out, and two staff commands that replaced hand-run production shell work — including a dry-run mode backed by a single source of truth per nudge, so the preview can't check a gate the real send doesn't apply.

What I learned

  1. Time is the whole problem. Three separate bugs were purely temporal: a backfill that clamped every elapsed occurrence to now and fired them at once; a DST transition drifting a reminder off the calendar invite it mirrored; and a holiday helper that hands the US calendar and a Sat/Sun weekend to nearly every timezone — which would have armed an entire plan with nothing for a manager on a Sunday–Thursday week.
  2. The best design work I did here never shipped. I built a full claim-before-deliver protocol — claimed state, widened index, compare-and-swap takeover, stale-claim sweeper — then closed all three PRs after establishing that production runs a single worker, which makes execution strictly serial and the race impossible. The rationale and its reopen trigger went into the repo docs so nobody has to rediscover it.
  3. Constraints beat conventions. Every "we'll just check before sending" scheme is one concurrent worker away from being wrong. The index doesn't care how careful the calling code is.