Multi-tenant notification router. Receives alerts from many source systems, normalizes them, routes them to the right people (companies → groups → individuals), and delivers via FCM, Telegram, SMS, email, voice, Slack, Teams, and custom webhooks.
Alert shape.companies / groups / individuals tables
subscriptions (opt-in per source/severity).dev-team supergroup.[ Sources ]
│ HTTP POST (webhook) ── HMAC-SHA256, API key, optional mTLS
│ WebSocket (client push) ── JWT, TLS
│ MQTT (broker sub) ── per-company topic, QoS 1
▼
[ INGEST TIER ] (ingestd, stateless, N replicas behind LB)
│ - AuthN/Z (API key / mTLS / JWT)
│ - Rate limit (Redis token bucket per source)
│ - Schema validate (per source type)
│ - Normalize → Alert v1
│ - Dedupe check (Redis SET NX, TTL 60s, key=hash(source|dedupe_key))
│ - Stamp dedupe_count on alert if already seen
│ - Publish to broker (NATS JetStream, subject: alerts.<company_id>)
▼
[ BROKER ] NATS JetStream
│ - Persistent stream `ALERTS` (replicas=3, retention 24h)
│ - Per-company subjects for partition affinity
▼
[ ROUTER TIER ] (routerd, stateless, N replicas, JetStream consumer)
│ - Consume alert
│ - Resolve recipients:
│ companies → groups → individuals
│ ∩ subscriptions (opt-in, severity ≥ user.min_severity)
│ ∩ quiet_hours (unless severity == "inminent_colapse")
│ - For each recipient, enqueue 1..N Delivery jobs to:
│ deliveries.<channel>.<company_id> (channel-specific subjects)
▼
[ DELIVERY TIER ] (deliverd, one worker pool per channel)
│ - FCM: shared FCM project, per-token sends, FCM topic for groups>50
│ - Telegram: per-company bot token, message + inline keyboard
│ - SMS / Voice: pluggable provider (Twilio v1)
│ - Email: SMTP relay or SES
│ - Slack / Teams: incoming webhook URLs
│ - Custom outbound webhook: HMAC sign with company key
│ - Retry: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 512s)
│ max 10 attempts, then → Dead-Letter Queue (DLQ)
▼
[ DATA TIER ]
- PostgreSQL: tenants, sources, companies, groups, individuals,
subscriptions, telegram_bots, fcm_tokens, routing_rules
- TimescaleDB: alerts (hypertable, 7d retention), deliveries
(hypertable, 7d retention)
- ClickHouse: alerts_archive, deliveries_archive, aggregates
(indefinite, columnar, cheap to query)
- Redis: dedupe set, rate-limit counters, idempotency keys
-- Tenancy
companies(id, name, slug, status, fcm_shared, telegram_bot_token_enc,
rate_limit_per_sec, created_at)
-- People & devices
individuals(id, company_id, full_name, email, phone_e164, locale, tz,
telegram_chat_id, telegram_user_id,
telegram_invite_code, telegram_invite_expires_at,
status, created_at)
fcm_tokens(id, individual_id, token, device_id, platform, locale,
app_version, last_seen, status, created_at)
groups(id, company_id, name, description, fcm_topic, telegram_chat_id,
created_at)
group_members(group_id, individual_id, added_at, added_by)
-- Source allowlist
sources(id, company_id, name, type, hmac_secret_enc, api_key_hash,
mtls_required, rate_limit_per_sec, allowed_ips cidr[],
topic_prefix, created_at)
-- Routing
subscriptions(id, individual_id, source_id, min_severity,
channel_mask, quiet_hours_start, quiet_hours_end, tz)
routing_rules(id, company_id, priority, match_expr, target_type,
target_id, enabled)
-- Normalized alert (Timescale hypertable)
alerts(id, company_id, source_id, severity, category, title_key,
body_key, data jsonb, dedupe_key, dedupe_count, received_at)
-- hypertable on received_at, 7d retention, then COPY to ClickHouse
-- Delivery attempts (Timescale hypertable)
deliveries(id, alert_id, individual_id, channel, target, status,
attempts, last_error, next_attempt_at, completed_at)
-- hypertable on completed_at (NULL = in-flight), 7d retention
| severity | sound | bypasses_quiet_hours | fcm_priority | example |
|---|---|---|---|---|
info |
default | no | normal | "deploy finished" |
warning |
alert | no | normal | "disk 80% full" |
critical |
siren | no | high | "service down" |
inminent_colapse |
klaxon | yes | high | "rack PDU overload imminent" |
category is free-form per source (e.g. network, power, security,
hvac, process). The Android app uses category to pick a custom sound
file (res/raw/siren_<category>.ogg).
When ingestd receives alert with dedupe_key:
key = hash(source_id || ':' || dedupe_key)
SET key 1 NX EX 60 -- first arrival creates a 60s window
SET succeeded → first arrival, publish as new alert.SET failed → existing window. INCR the counter on the key and
attach dedupe_count to the published alert payload. The recipient
sees e.g. "Disk full (×12 in 60s)" instead of 12 separate pushes.The dedupe window default is 60s and is configurable per source.
For an inbound alert:
recipients = ∅
for each target (group or individual) implied by source.allowed_targets:
if routing_rule matches alert.tags → override targets
individuals = expand(target) -- groups → members
for each individual:
sub = active_subscription(individual, source, alert.severity)
if not sub: skip
if in_quiet_hours(sub, now, alert.severity): skip
-- severity=inminent_colapse never skipped
for each channel in sub.channel_mask:
recipients += (individual, channel)
The router uses prepared statement + batch IN-list to avoid N+1.
For groups > 50 members, the router uses FCM topic messaging
(/topics/<group_fcm_topic>) which is one FCM API call regardless of
subscriber count. For smaller groups, it uses per-token sends.
data.company_id and renders a "company badge").companies.fcm_shared=false.A single FCM project can hold this. Storage is on our side (the
fcm_tokens table), not FCM's. We shard the table by company_id % N
(partition by hash) for vacuum/index perf.
@company_x_bot).
Token stored encrypted at rest (telegram_bot_token_enc).telegram_chat_id (resolved when
user runs /start in the bot).telegram_chat_id on the groups table)./start <invite_code> — link telegram account to the
pre-existing individuals.id (admin must create the
individual and issue the code first; unknown users are
rejected)/mute <duration> — e.g. /mute 2h, /mute until 18:00/unmute/subscribe <source> <min_severity>/unsubscribe <source>/preferences — show current subscriptions/status — last N alerts receiveddeliveries.status and (optionally) closes the alert via a webhook
callback to the source system.attempt 1, fail → wait 1s
attempt 2, fail → wait 2s
...
attempt 10, fail → publish to dlq.<channel>.<company_id>
DLQ is a JetStream stream with no consumer in normal operation, plus a DLQ table in Postgres for the admin UI. An operator can:
deliverd exposes /health and Prometheus metrics
(deliverd_delivery_attempts_total{channel,status},
deliverd_delivery_latency_seconds_bucket{channel}).
ingestd_alerts_received_total{source_type,result}ingestd_dedupe_hits_totalrouterd_recipient_expansion_secondsdeliverd_delivery_attempts_total{channel,status}deliverd_dlq_total{channel}trace_id, company_id,
alert_id, individual_id.traceparent through NATS headers).BA_MASTER_KEY).X-BA-Signature: t=<unix_ts>,v1=<hex_hmac> over
timestamp.body, with 5-min replay window). GitHub-style is fine too
(header X-Hub-Signature-256: sha256=...) but Stripe's timestamp
prevents replay; we ship that one as the default and accept
GitHub-style as a per-source option.alerts_archive, then drop chunk.deliveries_archive, then drop chunk."Don't understand this point, can you elaborate?"
This is about where the data lives and who has to obey whose laws:
us-east-1): cheapest, simplest, all
customers served from one data center. Fine if your 10k companies
don't have legal requirements to keep data in a specific country.For v1: single region, but pick the FCM endpoint that matches
(e.g. fcm.googleapis.com vs fcm.googleapis.eu). Document the
limitation; revisit at 1k paying companies.
"Also don't know the implications of this."
Three things that matter at 10k companies:
DELETE /individuals/:id must
hard-delete the row + cascade fcm_tokens + subscriptions. Keep a
tombstone (id, deleted_at) in a separate erased_individuals table
for 30 days so a re-created account doesn't double-deliver. ClickHouse
archives get the individual's id zeroed (we keep analytics counts
but no PII).If any customer is in healthcare/finance, add HIPAA/SOC2 controls later (audit log, BAAs). Not v1.
| mechanism | what it proves | who needs it | v1 |
|---|---|---|---|
API key (X-BA-Key: <opaque>) |
"the caller knows the secret" | any HTTP source | ✅ default, always on |
| HMAC signature | "the caller has the secret AND the body wasn't tampered with" | higher-trust sources, public webhooks | ✅ default-on, Stripe-style |
| mTLS | "the caller controls this exact client cert" | enterprise on-prem, very high trust | ✅ optional per source |
Recommendation: API key + HMAC for everyone, mTLS opt-in for sources that want it. API key alone is fine for trusted internal sources. HMAC alone is the public-webhook norm. mTLS is overkill for v1 unless a customer asks.
| protocol | pros | cons | fit |
|---|---|---|---|
| MQTT | tiny, perfect for IoT, persistent sessions, QoS guarantees | broker to operate, clients must speak MQTT | great for sensor gateways, PLCs, on-prem devices |
| WebSocket ingest | no broker, plain TLS, works from any browser/curl | we have to do the reconnect logic | great for browser dashboards, ops consoles |
| HTTP POST | dumb simple, every device can do it | no push back, no auto-reconnect magic | great for batch sources (Prometheus, Grafana) |
Verdict: support all three. MQTT is the right choice for IoT sources (cameras, sensors, PLCs) but not the only one. We'd use EMQX (or Mosquitto) as the broker.
Per-company topic prefix: yes — ba/<company_slug>/<source_id>/...
so a misbehaving publisher can be ACL'd to its prefix only.
QoS 1 vs 2:
| FCM topic messaging | Per-token send | |
|---|---|---|
| 1 push to N devices | 1 FCM API call | N FCM API calls |
| Latency at 1000 devices | ~200ms | ~30s with batching |
| Subscriptions dynamic | user subs/unsubs via app | we manage the list |
| Delivery report | aggregate only | per-token |
Rule: if a group has > 50 members and is the primary target, use FCM topic messaging. Otherwise, per-token is fine and gives us per-device delivery reports.
golang-migrateadmindbroad-announce/
├── SPEC.md ← this file
├── ARCHITECTURE.md ← diagrams, sequence flows, SLOs
├── PROMPT.md ← build log, decisions, lessons
├── README.md
├── docker-compose.yml ← one-shot local stack
├── .env.example
├── migrations/ ← sql migrations (golang-migrate)
├── ingestd/ ← binary: HTTP/WS/MQTT ingest
├── routerd/ ← binary: recipient expansion
├── deliverd/ ← binary: per-channel delivery workers
├── admind/ ← binary: admin HTTP API + (later) UI host
├── internal/
│ ├── alert/ ← Alert v1 type, validation
│ ├── broker/ ← NATS wrapper
│ ├── store/ ← Postgres + Timescale + ClickHouse
│ ├── dedupe/ ← Redis dedupe
│ ├── ratelimit/ ← Redis token bucket
│ ├── fcm/ ← FCM HTTP v1 client + topic mgmt
│ ├── telegram/ ← Bot client, command parser
│ ├── retry/ ← exponential backoff + DLQ
│ ├── observability/ ← slog + OTel + Prometheus
│ └── config/ ← env-driven config
└── deploy/
├── prometheus/
├── grafana/
└── loki/
| # | milestone | exit criterion |
|---|---|---|
| M0 | Skeleton + docker-compose up | Postgres + NATS + Redis up, all 4 services start, /health green |
| M1 | HTTP POST ingest end-to-end | send a signed webhook → FCM test push to a fake device |
| M2 | Recipient resolution | per-source allowed_targets honored, subscriptions applied |
| M3 | Telegram delivery + bot commands | user can /subscribe and receive an alert via Telegram |
| M4 | MQTT ingest | EMQX up, QoS 1, per-company topic ACLs |
| M5 | WebSocket ingest + live tail | admin UI (or wscat) sees alerts as they arrive |
| M6 | Dedupe + dedupe_count | burst of 100 identical alerts → recipient sees "×100" |
| M7 | Timescale + ClickHouse | 7d retention + archive job |
| M8 | DLQ + replay UI | operator can replay a failed delivery |
| M9 | Observability (Prom/Grafana) | 1 dashboard per tier + per-company drilldown |
| M10 | Load test 50k/s | soak 10 min, p99 ≤ 5s, zero DLQ |