Jelajahi Sumber

docs: SPEC + ARCHITECTURE + PROMPT scaffold for broad-announce

- SPEC.md: requirements, entities, severity taxonomy, dedupe model,
  recipient resolution, FCM, Telegram, retry/DLQ, observability,
  security, retention, capacity model, milestones
- ARCHITECTURE.md: system context, sequence diagrams, NATS subject
  layout, storage topology, SLOs, K8s migration notes
- PROMPT.md: build log with v1 decisions and open questions
- README.md: rewrite to point at SPEC + ARCHITECTURE
- .gitignore: Go + Docker + IDE exclusions
Luis Rosales 2 bulan lalu
induk
melakukan
fa36e75d7c
5 mengubah file dengan 861 tambahan dan 0 penghapusan
  1. 21 0
      .gitignore
  2. 317 0
      ARCHITECTURE.md
  3. 52 0
      PROMPT.md
  4. 38 0
      README.md
  5. 433 0
      SPEC.md

+ 21 - 0
.gitignore

@@ -0,0 +1,21 @@
+# Local dev
+.env
+.env.local
+*.log
+.DS_Store
+.idea/
+.vscode/
+*.swp
+
+# Build artifacts
+/bin/
+/dist/
+*.test
+*.out
+coverage.*
+
+# Go
+/vendor/
+
+# Docker
+docker-compose.override.yml

+ 317 - 0
ARCHITECTURE.md

@@ -0,0 +1,317 @@
+# Broad-Announce — Architecture
+
+> Companion to `SPEC.md`. This doc is the "how does it actually work" view.
+> See SPEC for requirements, entities, and the why.
+
+## 1. System context
+
+```mermaid
+flowchart LR
+    subgraph S[Sources]
+        WH[Webhook / HTTP]
+        WS[WebSocket clients]
+        MQ[MQTT publishers]
+    end
+    subgraph BA[Broad-Announce]
+        IG[ingestd]
+        BR[(NATS JetStream)]
+        RT[routerd]
+        DV[deliverd]
+        AD[admind]
+    end
+    subgraph DATA[Data tier]
+        PG[(Postgres + Timescale)]
+        CH[(ClickHouse archive)]
+        RD[(Redis)]
+    end
+    subgraph OUT[Delivery sinks]
+        FCM[FCM / Android]
+        TG[Telegram]
+        SMS[SMS / Voice]
+        EM[Email]
+        SL[Slack]
+        MT[MS Teams]
+        WH2[Custom outbound webhooks]
+    end
+    WH --> IG
+    WS  --> IG
+    MQ  --> IG
+    IG  --> RD
+    IG  --> BR
+    BR  --> RT
+    RT  --> PG
+    RT  --> BR
+    BR  --> DV
+    DV  --> FCM
+    DV  --> TG
+    DV  --> SMS
+    DV  --> EM
+    DV  --> SL
+    DV  --> MT
+    DV  --> WH2
+    PG  -- archive job --> CH
+    AD  --> PG
+    AD  --> BR
+    AD  --> DV
+```
+
+## 2. Sequence: a single alert through the system
+
+```mermaid
+sequenceDiagram
+    autonumber
+    participant Src as Source system
+    participant IG as ingestd
+    participant RD as Redis
+    participant BR as NATS JetStream
+    participant RT as routerd
+    participant PG as Postgres
+    participant DV as deliverd
+    participant FCM as FCM
+
+    Src->>IG: POST /v1/ingest (HMAC-signed, JSON)
+    IG->>IG: Verify HMAC, validate schema
+    IG->>RD: SET dedupe:{key} 1 NX EX 60
+    alt first arrival
+        RD-->>IG: OK
+        IG->>BR: publish alerts.<company_id> {Alert, dedupe_count=1}
+        IG-->>Src: 202 Accepted
+    else duplicate
+        RD-->>IG: nil
+        RD->>RD: INCR dedupe:{key}  → n
+        IG->>BR: publish alerts.<company_id> {Alert, dedupe_count=n}
+        IG-->>Src: 202 Accepted (deduped)
+    end
+
+    BR->>RT: deliver Alert
+    RT->>PG: SELECT recipients (batch, prepared)
+    PG-->>RT: individuals + channels
+    loop per (individual, channel)
+        RT->>BR: publish deliveries.<channel>.<company_id>
+    end
+
+    BR->>DV: deliver job
+    DV->>FCM: POST /v1/projects/.../messages:send
+    alt success
+        FCM-->>DV: 200, message_name
+        DV->>PG: UPDATE deliveries SET status='sent'
+    else transient error
+        DV->>DV: schedule retry (exp backoff)
+    else non-retryable
+        DV->>BR: publish dlq.<channel>.<company_id>
+    end
+```
+
+## 3. Recipient resolution algorithm
+
+```mermaid
+flowchart TD
+    A[Alert arrives in routerd] --> B{Resolve targets}
+    B --> C[source.allowed_targets]
+    C --> D{routing_rules overrides?}
+    D -- yes --> E[apply rules in priority order]
+    D -- no  --> F[use source targets]
+    E --> G[expand groups → members]
+    F --> G
+    G --> H{active subscription?}
+    H -- no --> X[skip]
+    H -- yes --> I{severity ≥ sub.min_severity?}
+    I -- no --> X
+    I -- yes --> J{in quiet_hours AND severity != inminent_colapse?}
+    J -- yes --> X
+    J -- no --> K[emit one Delivery per channel in sub.channel_mask]
+```
+
+## 4. Storage topology
+
+```mermaid
+flowchart LR
+    APP[App services] --> PG[(Postgres 16)]
+    PG --- TS[(Timescale 2.x extension)]
+    TS --- HG1[hypertables: alerts, deliveries<br/>7d retention]
+    PG --- CORE[regular tables: companies,<br/>individuals, groups, fcm_tokens,<br/>sources, subscriptions, bots]
+    APP --> RD[(Redis 7)]
+    RD --- DEDUPE[dedupe keys, TTL 60s]
+    RD --- RL[rate-limit token buckets]
+    PG -- nightly cron --> CH[(ClickHouse 24.x)]
+    CH --- AR1[alerts_archive]
+    CH --- AR2[deliveries_archive]
+    CH --- AGG[materialized aggregates<br/>per-company, per-channel]
+```
+
+## 5. NATS subject layout
+
+| subject | producer | consumer | retention |
+|---|---|---|---|
+| `alerts.<company_id>` | ingestd | routerd | 24h (JetStream stream `ALERTS`) |
+| `deliveries.fcm.<company_id>` | routerd | deliverd-fcm | 1h |
+| `deliveries.telegram.<company_id>` | routerd | deliverd-telegram | 1h |
+| `deliveries.sms.<company_id>` | routerd | deliverd-sms | 1h |
+| `deliveries.email.<company_id>` | routerd | deliverd-email | 1h |
+| `deliveries.slack.<company_id>` | routerd | deliverd-slack | 1h |
+| `deliveries.teams.<company_id>` | routerd | deliverd-teams | 1h |
+| `deliveries.webhook.<company_id>` | routerd | deliverd-webhook | 1h |
+| `dlq.<channel>.<company_id>` | deliverd-* | (operator) | 7d |
+
+Subject-based partitioning keeps a single company's alert stream in
+order (mostly) and lets us add per-company worker affinity in K8s later.
+
+## 6. FCM delivery paths
+
+```mermaid
+flowchart TD
+    A[deliverd-fcm job] --> B{group_fcm_topic set<br/>AND group size > 50?}
+    B -- yes --> C[POST to /topics/&lt;topic&gt; : send<br/>1 FCM call]
+    B -- no --> D[For each device token in target]
+    D --> E[POST : send<br/>1 FCM call per token]
+    C --> F[log delivery aggregate only]
+    E --> G[log per-token delivery report]
+```
+
+### FCM payload shape
+
+```json
+{
+  "message": {
+    "token": "<device_token>",
+    "notification": {
+      "title": "Disk full on db-prod-03",
+      "body": "92% used (×12 in 60s)"
+    },
+    "data": {
+      "company_id": "acme",
+      "alert_id": "01HXYZ...",
+      "severity": "critical",
+      "category": "storage",
+      "dedupe_count": "12",
+      "deep_link": "broadannounce://alert/01HXYZ"
+    },
+    "android": {
+      "priority": "HIGH",
+      "notification": {
+        "sound": "siren_storage",
+        "channel_id": "alerts.critical"
+      }
+    }
+  }
+}
+```
+
+The Android app reads `data.category` and `data.severity` to pick
+the right sound + channel.
+
+## 7. Telegram bot surface
+
+```mermaid
+sequenceDiagram
+    participant U as User
+    participant TG as Telegram
+    participant BOT as deliverd-telegram
+    participant BR as NATS
+    participant RT as routerd
+    participant PG as Postgres
+
+    U->>TG: /start
+    TG->>BOT: update (chat_id, from.id)
+    BOT->>PG: link telegram_chat_id to individual
+    Note over BOT,PG: Individual must pre-exist; user supplies email/phone to match
+
+    U->>TG: /mute 2h
+    TG->>BOT: update
+    BOT->>PG: UPDATE subscriptions SET mute_until = now()+2h
+
+    Note over RT,PG: New alert arrives
+    RT->>PG: resolve recipients incl. telegram
+    RT->>BR: deliveries.telegram.<company_id>
+    BR->>BOT: job
+    BOT->>TG: sendMessage(chat_id, text, reply_markup)
+
+    U->>TG: taps [Acknowledge]
+    TG->>BOT: callback_query
+    BOT->>BR: ack.<alert_id>.<individual_id>
+    BR->>RT: close alert (or notify source)
+```
+
+## 8. Failure & retry
+
+| layer | failure mode | behavior |
+|---|---|---|
+| ingestd | source 5xx storm | rate-limit per source, return 429 |
+| ingestd | broker down | 503 to source, source retries |
+| routerd | DB down | nack to broker, message redelivered (no ack until DB write) |
+| deliverd | third-party 5xx | exp backoff, max 10, then DLQ |
+| deliverd | third-party 4xx (non-retryable) | straight to DLQ |
+| FCM | token unregistered | mark `fcm_tokens.status='unregistered'`, skip on next send |
+| Telegram | chat not found | mark `individuals.telegram_status='revoked'`, skip |
+
+## 9. SLOs
+
+| SLI | target | measured at |
+|---|---|---|
+| Ingest availability | 99.9% | ingress LB |
+| Ingest latency p99 | ≤ 100ms | ingestd |
+| End-to-end latency p99 | ≤ 5s | accept → device wake |
+| Delivery success rate | ≥ 99.5% (excluding DLQ) | deliverd |
+| DLQ rate | < 0.5% of deliveries | deliverd |
+| Per-company data isolation | 100% (no cross-tenant queries possible) | audit + e2e test |
+
+## 10. Capacity model (back-of-envelope)
+
+Assumption: 50k alerts/sec peak, avg fan-out 10 recipients × 2 channels = 20 deliveries per alert.
+
+- **ingestd**: 1 alert ≈ 200µs (validate + Redis + NATS publish).
+  4 cores handle 20k/s. → 12 replicas for 50k/s with headroom.
+- **routerd**: recipient expansion is the hot path. 1 expansion ≈ 2ms
+  (one batched query). 4 cores ≈ 2k/s per replica. → 25 replicas
+  for 50k/s × 2ms ≈ 5s p99.
+- **deliverd-fcm**: FCM HTTP v1 p95 ~150ms. Per worker, ~6 msg/s
+  serialised, or 60 msg/s with 10 in-flight. 1M deliveries/min needs
+  ~280 workers. We budget 500.
+- **deliverd-telegram**: TG API p95 ~100ms. ~10 msg/s serialised per
+  worker. 1M/min → 1700 workers. We budget 2000.
+
+Total: ~2700 service processes for peak. Docker Compose can't carry
+this — that is exactly the SLO that forces K8s in v2.
+
+For v1 we target **5k alerts/sec** sustained in the docker-compose
+profile, and gate "50k/s" on the K8s + multi-broker milestone.
+
+## 11. Security model
+
+```mermaid
+flowchart LR
+    subgraph perimeter
+        LB[TLS 1.2+ LB]
+    end
+    subgraph mTLS[mTLS optional per source]
+        M1[client cert verify]
+    end
+    subgraph auth[Auth]
+        K1[API key in X-BA-Key<br/>argon2id at rest]
+        K2[HMAC-SHA256<br/>Stripe-style X-BA-Signature]
+        K3[JWT for WS clients]
+    end
+    subgraph data[At rest]
+        S1[AES-256-GCM<br/>BA_MASTER_KEY]
+    end
+    LB --> mTLS --> auth
+    auth --> APP[services]
+    APP --> data
+    APP --> PG
+    APP --> CH
+    APP --> RD
+```
+
+## 12. What changes when we move to K8s
+
+- ingestd: HPA on CPU + custom metric `ingestd_queue_depth`
+- routerd: HPA on CPU + custom metric `routerd_pending_messages`
+- deliverd: HPA per channel, custom metric `deliverd_inflight`
+- NATS JetStream: 3-node cluster, replicas=3, ack-quorum=2
+- Postgres: managed (RDS / Cloud SQL) + 1 read replica for archive
+- Redis: managed (ElastiCache / Memorystore), cluster mode off for v1
+- ClickHouse: 2 shards, 1 replica, Zookeeper-less (CH 24 supports it)
+- MQTT broker: EMQX cluster, 3 nodes, per-company topic ACLs
+
+For v1 we keep all of the above **out of scope** and run a single
+node of each on a beefy docker-compose host.

+ 52 - 0
PROMPT.md

@@ -0,0 +1,52 @@
+# Broad-Announce — Build Log (PROMPT)
+
+> Decisions, lessons, blockers. Append-only. Update as we go.
+
+## 2026-06-13 — kickoff
+
+**Decided**
+- Repo at `git3.techno-world.net/lrosales/broad-announce` (private).
+- v1 stack: Go, PostgreSQL + Timescale, ClickHouse, NATS JetStream,
+  Redis, EMQX (MQTT), Prometheus + Grafana, Loki. Deploy v1 = Docker
+  Compose. v2 = K8s.
+- Multi-tenancy = **shared infrastructure, strict app-level isolation**.
+  No row-level security in v1; tenant filter on every query.
+- FCM = **single shared project** for v1, schema supports per-company
+  FCM project later (`companies.fcm_shared`).
+- Telegram is a **first-class delivery channel** + a **management UI**
+  (the bot is how users mute, subscribe, acknowledge).
+- Severity taxonomy: `info | warning | critical | inminent_colapse`.
+  Only `inminent_colapse` bypasses quiet hours.
+- Dedupe: 60s window per `(source_id, dedupe_key)`, attach
+  `dedupe_count` so user sees "×N in 60s" not N pushes.
+- v1 capacity target in docker-compose: **5k alerts/sec sustained**.
+  50k/sec is the design ceiling; the K8s + multi-broker work
+  is what unlocks it.
+
+**Open**
+- Bot ↔ individual linking: need a clean flow. The current plan is
+  user runs `/start <email_or_phone>` and we match against
+  `individuals.email` / `phone_e164`. Open question: do we let
+  unknown users self-register via the bot, or do we require an
+  admin to create the `individuals` row first? **Toward stricter
+  is better** (admin invites, bot confirms).
+- Localized titles: we need a `title_key` / `body_key` model, or
+  accept pre-localized strings per-recipient from the source.
+  Starting simple: source sends a single `title`/`body`; we
+  do **not** localize in v1. If a customer needs localization, they
+  send the alert multiple times (one per locale) keyed on
+  `subscriptions.locale`. Re-evaluate at v2.
+- mTLS: schema supports it, but the docker-compose profile won't
+  terminate client certs in v1. Documented as opt-in for
+  enterprise sources.
+
+**Lessons (already)**
+- It's much cheaper to answer "what *can* this be?" with 30
+  questions than to refactor later. Most of the SPEC's weight is
+  in §11 (security) and §6 (recipient resolution) — those are
+  the parts that are expensive to change after launch.
+- For multi-tenant at 10k companies, the *one* design choice
+  that compounds is the **subject layout in the broker**.
+  `alerts.<company_id>` is fine; `alerts.<company_id>.<source_id>`
+  would let us scale router consumer groups per source. Locked
+  in §5 of ARCHITECTURE.md.

+ 38 - 0
README.md

@@ -1 +1,39 @@
 # broad-announce
+
+Multi-tenant notification router. Receives alerts from many source
+systems (HTTP/JSON webhooks, WebSockets, MQTT), normalizes them,
+resolves recipients via `companies` → `groups` → `individuals` +
+`subscriptions`, and delivers to FCM (Android), Telegram, SMS, email,
+voice, Slack, MS Teams, and arbitrary outbound webhooks.
+
+> **Status**: spec + architecture, no code yet. See `SPEC.md` for
+> requirements and `ARCHITECTURE.md` for diagrams / sequence flows /
+> capacity model. `PROMPT.md` is the build log.
+
+## v1 in one paragraph
+
+Four Go services (`ingestd`, `routerd`, `deliverd`, `admind`) wired
+together by NATS JetStream. Postgres + Timescale for live data,
+ClickHouse for archive, Redis for dedupe + rate limits, EMQX for
+MQTT. Strict app-level multi-tenant isolation. ~5k alerts/sec on
+Docker Compose, 50k/sec design ceiling for v2 K8s.
+
+## Repo layout
+
+```
+SPEC.md           — requirements, entities, severity, retention
+ARCHITECTURE.md   — diagrams, sequences, SLOs, capacity model
+PROMPT.md         — build log, decisions, open questions
+migrations/       — (coming) golang-migrate SQL files
+ingestd/          — (coming) HTTP/WS/MQTT ingest binary
+routerd/          — (coming) recipient resolution binary
+deliverd/         — (coming) per-channel delivery workers
+admind/           — (coming) admin HTTP API + UI host
+internal/         — (coming) shared Go packages
+deploy/           — (coming) prometheus, grafana, loki configs
+docker-compose.yml — (coming) one-shot local stack
+```
+
+## License
+
+Private. © 2026 Techno-World.

+ 433 - 0
SPEC.md

@@ -0,0 +1,433 @@
+# 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.<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
+```
+
+### 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_<category>.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/<group_fcm_topic>`) 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 <duration>` — e.g. `/mute 2h`, `/mute until 18:00`
+  - `/unmute`
+  - `/subscribe <source> <min_severity>`
+  - `/unsubscribe <source>`
+  - `/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.<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:
+- 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=<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.
+- 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: <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.
+
+## 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/<company_slug>/<source_id>/...`
+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 |