# Broad-Announce — Project Spec (v1) > 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. ## 1. Goals (v1) - Accept alerts via **HTTP POST (JSON)**, **WebSockets (client→server)**, and **MQTT**. - Normalize every inbound alert to a single internal `Alert` shape. - Resolve recipients by querying `companies` / `groups` / `individuals` tables + per-user `subscriptions` (opt-in per source/severity). - Deliver to recipients via **FCM** (primary), **Telegram** (primary), **SMS**, **email**, **voice call**, **Slack**, **MS Teams**, and **generic outbound webhook**. - Sustain **~50,000 alerts/sec** with **≤5s p99** end-to-end latency (accept → device wake). - Multi-tenant: **~10,000 companies**, up to **1,000 devices per company**. - Strict data isolation: a company can never see another company's alerts, recipients, or delivery logs. ## 2. Non-goals (v1) - No inbound email parsing, no SMS shortcode receivers, no phone IVR trees. - No on-prem appliance mode. We deploy as Docker Compose first, K8s later. - No cross-company fan-out except an explicit `dev-team` supergroup. - No iOS APNs in v1 (FCM only). APNs comes in v2. ## 3. Multi-tier architecture ``` [ 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.) ▼ [ 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-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 ``` ### Why 4 tiers instead of 1 monolith - **Ingest** scales with incoming HTTP/WS/MQTT concurrency; it's CPU-cheap but connection-heavy. Independent replicas behind an L7 LB. - **Router** is CPU + DB heavy (recipient expansion can be 1 → 10,000s). It must not be on the request path of the source system — once accepted, the alert is in the broker and the source is acked. - **Delivery** is the slowest tier (third-party API calls). Each channel is a separate worker pool with its own concurrency, timeouts, and circuit breaker. - **Broker** is the buffer. If delivery falls behind, alerts queue up instead of timing out the source. ## 4. Entities (Postgres schema sketch) ```sql -- 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, 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 taxonomy (used everywhere) | 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_.ogg`). ## 5. Dedupe + dedupe_count 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 ``` - If `SET` succeeded → first arrival, publish as new alert. - If `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. ## 6. Recipient resolution (the heart of the router) 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/`) which is one FCM API call regardless of subscriber count. For smaller groups, it uses per-token sends. ## 7. FCM (shared project model) - **One FCM project** for all companies. App must be multi-tenant aware (the Android app reads `data.company_id` and renders a "company badge"). - Per-individual **device tokens** registered by the Android app on login. - Per-group **FCM topic** if group size > 50. - Quota: FCM HTTP v1 default is 600k msg/min to a project, 1k msg/min per token recipient is the realistic soft cap. At 50k alerts/sec, even bursty, we stay well under if we use **topics for big groups** and **dedupe** to collapse storms. If a single company ever exceeds per-project quota, we can **shard into a second FCM project** — schema already supports `companies.fcm_shared=false`. ### 10k companies × 1k devices = 10M tokens 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. ## 8. Telegram (primary channel for ops) - Each company registers one or more **Telegram bots** (`@company_x_bot`). Token stored encrypted at rest (`telegram_bot_token_enc`). - For 1:1 alerts we need the user's `telegram_chat_id` (resolved when user runs `/start` in the bot). - For group alerts we use the bot in a company group chat (`telegram_chat_id` on the `groups` table). - **Bot commands** (Telegram = management UI): - `/start` — link telegram account to `individuals.id` - `/mute ` — e.g. `/mute 2h`, `/mute until 18:00` - `/unmute` - `/subscribe ` - `/unsubscribe ` - `/preferences` — show current subscriptions - `/status` — last N alerts received - Telegram is bidirectional: users reply to acknowledge; ack updates `deliveries.status` and (optionally) closes the alert via a webhook callback to the source system. ## 9. Delivery retry & DLQ ``` attempt 1, fail → wait 1s attempt 2, fail → wait 2s ... attempt 10, fail → publish to dlq.. ``` DLQ is a **JetStream stream** with no consumer in normal operation, plus a **DLQ table** in Postgres for the admin UI. An operator can: - View failed deliveries, with full payload + last error - Replay (re-enqueue) one or many - Discard `deliverd` exposes `/health` and Prometheus metrics (`deliverd_delivery_attempts_total{channel,status}`, `deliverd_delivery_latency_seconds_bucket{channel}`). ## 10. Observability (operate from day 1) - **Prometheus** metrics on every service: - `ingestd_alerts_received_total{source_type,result}` - `ingestd_dedupe_hits_total` - `routerd_recipient_expansion_seconds` - `deliverd_delivery_attempts_total{channel,status}` - `deliverd_dlq_total{channel}` - **Structured logs** (slog JSON) with `trace_id`, `company_id`, `alert_id`, `individual_id`. - **OpenTelemetry traces** across ingest → broker → router → delivery (propagate `traceparent` through NATS headers). - **Grafana dashboards**: per-company drill-down, channel health, DLQ rate, p50/p95/p99 latency. ## 11. Security - Secrets at rest: FCM service account JSON, Telegram bot tokens, HMAC secrets → AES-256-GCM with key from env (`BA_MASTER_KEY`). - TLS 1.2+ everywhere; mTLS optional for HTTP sources. - Per-source HMAC: scheme = **Stripe-style** (header `X-BA-Signature: t=,v1=` 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. - API keys: argon2id hashed at rest. - Postgres row-level security optional (off by default; tenant filter is enforced in app code paths). ## 12. Data retention - **alerts** (Timescale): 7 days, then COPY → ClickHouse `alerts_archive`, then drop chunk. - **deliveries** (Timescale): 7 days, then COPY → ClickHouse `deliveries_archive`, then drop chunk. - **companies / individuals / fcm_tokens / groups / subscriptions**: indefinite in Postgres. GDPR right-to-be-forgotten deletes the individual + cascades fcm_tokens + subscriptions. - **ClickHouse**: indefinite. Partition by month, TTL optional later. ## 13. Region / data residency (Q24) > "Don't understand this point, can you elaborate?" This is about **where the data lives** and **who has to obey whose laws**: - **Single region** (e.g. one AWS `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. - **Multi-region active-passive** (us-east-1 primary, eu-west-1 standby): you can failover, but EU customers' data still passes through the US at some point. Needed if you want to claim EU residency for marketing. - **Multi-region active-active** (every region runs a full stack, broker replicated, Postgres logical-replicated): each company pinned to one region. Expensive, complex, but lets you say "company X's alerts never leave Frankfurt." 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. ## 14. Compliance (Q25) > "Also don't know the implications of this." Three things that matter at 10k companies: 1. **GDPR / right to be forgotten**: a `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). 2. **Data Processing Agreement** (DPA) with each customer: required by GDPR. We are the processor; they are the controller. 3. **Encryption at rest + in transit** (already in §11). SOC2 auditors care; GDPR does too indirectly. If any customer is in healthcare/finance, add HIPAA/SOC2 controls later (audit log, BAAs). Not v1. ## 15. mTLS vs HMAC vs API key (Q8) | mechanism | what it proves | who needs it | v1 | |---|---|---|---| | **API key** (`X-BA-Key: `) | "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. ## 16. MQTT (Q9) — is it the right choice? | 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///...` so a misbehaving publisher can be ACL'd to its prefix only. **QoS 1 vs 2**: - **QoS 1** (at-least-once): broker acks on receipt, message may be delivered twice on reconnect. **Default for alerts.** With our dedupe layer this is fine. - **QoS 2** (exactly-once): 4-step handshake, slower, ensures no dup. Overkill given our 60s dedupe window. Skip. ## 17. FCM topic vs per-token fan-out (Q14) | | 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. ## 18. Stack - **Language**: Go 1.22+ - **DB**: PostgreSQL 16 + TimescaleDB 2.x (active window), ClickHouse 24.x (archive) - **Broker**: NATS JetStream (single-node for v1, 3-node later) - **Cache / dedupe / rate-limit**: Redis 7 - **MQTT broker**: EMQX 5.x (Docker image) - **Observability**: Prometheus + Grafana + Loki + OTel collector - **Deployment**: Docker Compose (v1), Kubernetes manifests (v2) - **Migrations**: `golang-migrate` - **No-code admin UI** (later): React + Vite, served by `admind` ## 19. Repo layout (planned) ``` broad-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/ ``` ## 20. Milestones | # | 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 |