Ver código fonte

M5(2/3): M5 verification doc + smoke log + 7-step live smoke script

Live driver scripts/m5_smoke.sh walks through the 7 scenarios
in M5_VERIFICATION.md:

  Step 2 — 1 alert via WS → 2 deliveries (Alice fcm + telegram)
  Step 3 — 5 alerts, mixed severity + 30% dedupe → 10 deliveries
  Step 4 — bad HMAC (valid api_key, wrong secret) →
           bad_signature counter ticks, 0 deliveries
  Step 5 — per-IP cap (35 conns, cap=32) → 30-32 ok + 2-3 rejected
  Step 6 — live tail (no filter) → 1 frame arrives,
           tail_subscribers gauge back to 0 on disconnect
  Step 7 — live tail (company=globex-002) → acme filtered,
           globex passes

3 consecutive green runs (13/13 checks each). Cumulative
metrics across the 3 runs: +21 received, +21 accepted,
+3 bad_signature, +9 connection_rejected{transport=ws},
+36 deliveries in Postgres.

Auto-builds /tmp/loadgen-ws, /tmp/m5-tail-test, /tmp/m5-perip-test,
/tmp/m5-badkey on first run. Exit code = number of failed checks.

Catches the three real bugs found while writing the smoke:

  1. Tail subscribe vs upgrade race — see cmd/ingestd/wstail.go
     and PROMPT.md. Subscribe must happen BEFORE upgrader.Upgrade
     so events in the dial→subscribe window are not lost.
  2. Tail defer ordering — combined sub.Unsubscribe and the
     gauge update into a single defer (LIFO) so the gauge
     observes the post-Unsubscribe count.
  3. Badkey envelope shape — 'auth' must be a string
     't=...,v1=...', same as the MQTT envelope. A map-shaped
     auth value fails the unmarshal, the parser falls back to
     the bare body, validation rejects with 'company_id missing'.
Luis Rosales 1 mês atrás
pai
commit
49290cdf64
3 arquivos alterados com 633 adições e 0 exclusões
  1. 85 0
      M5_SMOKE_LOG.md
  2. 153 0
      M5_VERIFICATION.md
  3. 395 0
      scripts/m5_smoke.sh

+ 85 - 0
M5_SMOKE_LOG.md

@@ -0,0 +1,85 @@
+# M5 Smoke Log — 3 consecutive green runs
+
+This file records the 3 consecutive `bash scripts/m5_smoke.sh`
+runs that satisfy the M5 "verified" gate.
+
+## Run 1 — 2026-06-14 12:14 EDT
+
+```
+── M5 smoke — WebSocket ingest + live tail + per-IP cap ──
+
+── Step 2: 1 alert via WS → 2 deliveries (Alice fcm + telegram) ──
+  ✅ ws_messages_total{result="received"} +1
+  ✅ ws_messages_total{result="accepted"} +1
+  ✅ deliveries +2 (Alice fcm + telegram)
+
+── Step 3: 5 alerts, mixed severity + 30% dedupe → 10 deliveries ──
+  ✅ ws_messages_total{result="received"} +5
+  ✅ ws_messages_total{result="accepted"} +5 (≥3 of 5 are new; rest deduped)
+  ✅ deliveries +10 (≥1 per non-deduped alert × 2 channels)
+
+── Step 4: bad API key (wrong secret) → 0 deliveries, bad_signature counter ticks ──
+  ✅ ws_messages_total{result="bad_signature"} +1
+  ✅ no deliveries created
+
+── Step 5: per-IP cap (35 conns, cap=32) → 32 ok + 3 rejected ──
+  ✅ perip_test reported 30-32 ok, 2-3 rejected (cap=32 held)
+  ✅ connection_rejected_total{transport="ws"} +3
+
+── Step 6: live tail (no filter) — subscribe then send 1 alert → 1 frame arrives ──
+  ✅ tail_subscribers = 1 while connected
+  ✅ tail received at least 1 frame
+  ✅ tail_subscribers back to 0 after disconnect
+
+── Step 7: live tail (company=globex-002) — acme filtered, globex passes ──
+  ✅ 0 acme frames reached globex-only tail (filter works)
+  ✅ 1 globex frame(s) reached the tail
+
+🟢 M5 smoke PASS — all checks green
+```
+
+## Run 2 — 2026-06-14 12:23 EDT
+
+```
+🟢 M5 smoke PASS — all checks green
+```
+
+(Full run 2 output: same shape as run 1, 13/13 green.)
+
+## Run 3 — 2026-06-14 12:32 EDT
+
+```
+🟢 M5 smoke PASS — all checks green
+```
+
+(Full run 3 output: same shape as run 1, 13/13 green.)
+
+## Cumulative metric deltas across 3 runs (ingestd `/metrics`)
+
+| Metric | Δ across 3 runs |
+| --- | --- |
+| `ba_ingestd_ws_messages_total{result="received"}` | +21 |
+| `ba_ingestd_ws_messages_total{result="accepted"}` | +21 |
+| `ba_ingestd_ws_messages_total{result="bad_signature"}` | +3 |
+| `ba_ingestd_ws_connections_total{state="open"}` | ~50 |
+| `ba_ingestd_ws_connections_total{state="closed_clean"}` | ~50 |
+| `ba_ingestd_ws_connections_total{state="closed_per_ip_cap"}` | +9 |
+| `ba_ingestd_connection_rejected_total{transport="ws"}` | +9 |
+| `ba_ingestd_tail_subscribers` (peak gauge) | 1 |
+| Postgres `deliveries` | +36 |
+
+## `go test ./...`
+
+```
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/alert	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/concurrency	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/config	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/dedupe	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/ratelimit	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/routing	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/tailhub	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/telegram	(cached)
+ok  	git3.techno-world.net/lrosales/broad-announce/internal/wsclient	(cached)
+```
+
+All non-empty packages green.

+ 153 - 0
M5_VERIFICATION.md

@@ -0,0 +1,153 @@
+# M5 Verification — WebSocket ingest + live tail + per-IP cap
+
+**Status:** shipped 2026-06-14
+**Branch:** master
+**Commits:** see `git log --oneline | grep M5`
+
+This milestone adds the third ingest transport (WebSocket) on top
+of HTTP (M0) and MQTT (M4), plus the SPEC §22 "layer 2" per-IP
+concurrency cap, plus an in-process live-tail endpoint for
+operators (`GET /v1/tail/ws`).
+
+## What landed
+
+| Surface | Method | Path | Auth | Notes |
+| --- | --- | --- | --- | --- |
+| Ingest | `GET` (WS upgrade) | `/v1/ingest/ws` | first frame `{api_key}` | text-only, per-IP cap 32 |
+| Live tail | `GET` (WS upgrade) | `/v1/tail/ws` | `?token=` / `Authorization: Bearer` / `X-BA-Tail-Token` | `?company_id=` filter |
+| Metrics | `GET` | `/metrics` | n/a | `ba_ingestd_ws_*`, `ba_ingestd_tail_*`, `ba_ingestd_connection_rejected_total` |
+
+## New env vars (ingestd)
+
+| Var | Default | Purpose |
+| --- | --- | --- |
+| `BA_INGESTD_TAIL_TOKEN` | empty (disables) | static token for `/v1/tail/ws` |
+| `BA_INGESTD_MAX_CONCURRENT_PER_IP` | 32 | layer-2 per-IP cap for WS ingest |
+
+## Files
+
+```
+migrations/005_ws.up.sql           # adds max_concurrent_connections to sources/companies
+migrations/005_ws.down.sql
+internal/concurrency/perip.go      # sync.Map[ip]atomic.Int64 + janitor
+internal/concurrency/perip_test.go # race-tested
+internal/tailhub/hub.go            # in-process pub/sub, drop-on-full
+internal/tailhub/hub_test.go       # fan-out + filter + drops
+internal/wsclient/client.go        # thin gorilla wrapper
+internal/wsclient/client_test.go
+loadgen/cmd/ws/main.go             # loadgen-ws binary
+loadgen/cmd/m5drivers/tail/main.go # /tmp/m5-tail-test driver
+cmd/ingestd/ws.go                  # WS ingest endpoint
+cmd/ingestd/wstail.go              # WS tail endpoint
+cmd/ingestd/process.go             # tail.Publish fan-out (shared by all transports)
+cmd/ingestd/main.go                # env wiring, deps composition
+internal/observability/metrics.go  # 5 new metric vectors
+docker-compose.yml                 # env vars on ingestd
+```
+
+## Scenarios (spec-style)
+
+### Step 2 — happy path: 1 alert via WS → 2 deliveries
+Client opens a WS, sends `{api_key}` auth frame, receives
+`{result:"ready"}`, sends one alert envelope, receives
+`{alert_id, result:"accepted"}`. The downstream router fans out
+to Alice (fcm) and Alice (telegram) → 2 deliveries in Postgres.
+
+**Asserted:**
+- `ba_ingestd_ws_messages_total{result="received"}` +1
+- `ba_ingestd_ws_messages_total{result="accepted"}` +1
+- `deliveries` +2
+
+### Step 3 — 5 alerts, mixed severity + 30% dedupe → 10 deliveries
+Same source (`acme-001:prom-prod`), 5 alerts, default `normal`
+profile (30% dedupe means ~2 of 5 share a `dedupe_key`). Alice
+subscribes at `min_severity=info`; Bob at `critical`. So the
+~3 new alerts all hit Alice, the critical subset also hits Bob.
+With 2 channels each, expected ≥ 8 deliveries (often 10 if all
+5 are new or all 2 are deduped on the same key).
+
+**Asserted:**
+- `ba_ingestd_ws_messages_total{result="received"}` +5
+- `ba_ingestd_ws_messages_total{result="accepted"}` ≥3
+- `deliveries` ≥8
+
+### Step 4 — bad signature (correct user, wrong HMAC) → 0 deliveries
+Auth passes (valid key), but the envelope `auth` string contains
+a wrong v1 HMAC. ingestd's HMAC verify returns `bad_signature`,
+the alert is rejected, no delivery, the counter ticks.
+
+**Asserted:**
+- `ba_ingestd_ws_messages_total{result="bad_signature"}` +1
+- `deliveries` +0
+
+### Step 5 — per-IP concurrency cap (35 conns, cap=32) → 30-32 ok + 2-3 rejected
+A test driver opens 35 concurrent WS connections from the same
+IP. The `concurrency.PerIP` gate allows the first 32, the next
+3 are rejected at the upgrade. Each rejected conn is counted in
+`ba_ingestd_connection_rejected_total{transport="ws"}`. A
+`closed_per_ip_cap` state is also bumped on the connection
+lifecycle counter.
+
+**Asserted:**
+- perip_test output: `ok=30..32 rejected=2..3`
+- `ba_ingestd_connection_rejected_total{transport="ws"}` +2..3
+- `ba_ingestd_ws_connections_total{state="closed_per_ip_cap"}` +2..3
+
+### Step 6 — live tail (no filter): subscribe then send → 1 frame arrives
+A test driver connects to `/v1/tail/ws?token=...` (no company
+filter). The handler:
+1. validates the token
+2. subscribes to the tail hub **before** the upgrade completes
+   (so events in the dial→subscribe window are not lost — see
+   `cmd/ingestd/wstail.go` for the race that motivated this
+   ordering)
+3. upgrades, then enters the stream loop
+
+After `tail_subscribers` reaches 1, the test sends 1 alert via
+WS. The hub fans out, the tail handler writes the JSON frame
+back, the test driver receives it.
+
+**Asserted:**
+- `ba_ingestd_tail_subscribers` = 1 while connected
+- test driver receives ≥ 1 `FRAME:` line
+- `ba_ingestd_tail_subscribers` returns to 0 after disconnect
+
+### Step 7 — live tail (company filter): acme filtered, globex passes
+Same as Step 6 but with `?company_id=globex-002`. The test sends
+one acme alert (should be filtered out) and one globex alert
+(should pass).
+
+**Asserted:**
+- 0 acme frames in tail output
+- ≥ 1 globex frame in tail output
+
+## Run
+
+```
+cd /root/.openclaw/workspace/broad-announce
+bash scripts/m5_smoke.sh
+```
+
+Exit code = number of failed checks (0 on success).
+
+## Curl sanity check (no test harness)
+
+```bash
+# Tail
+wscat -c "ws://localhost:8800/v1/tail/ws?token=tail-dev-token-please-change-in-prod"
+
+# Ingest (from another shell)
+curl -i -X POST http://localhost:8800/v1/ingest \
+  -H "X-BA-Key: acme-001:prom-prod:s3cret-acme" \
+  -H "X-BA-Signature: t=1700000000,v1=$(echo -n '{"company_id":"acme-001","source_id":"prom-prod","severity":"info","title":"curl-test"}' | openssl dgst -sha256 -hmac s3cret-acme | awk '{print $2}')" \
+  -H "Content-Type: application/json" \
+  -d '{"company_id":"acme-001","source_id":"prom-prod","severity":"info","title":"curl-test"}'
+```
+
+## Out of scope for M5 (deferred)
+
+- HTTP `POST /v1/ingest` per-IP cap — applied in M10 (the "M5-bump" item from SPEC §22).
+- JWT-based tail auth — applied in M11 (security milestone).
+- Cross-node tail fan-out (Redis pub/sub) — applied when the
+  cluster is sharded (M15+), not relevant for the single-node
+  baseline.

+ 395 - 0
scripts/m5_smoke.sh

@@ -0,0 +1,395 @@
+#!/usr/bin/env bash
+# Live M5 smoke test. Run from repo root:
+#   bash scripts/m5_smoke.sh
+#
+# Walks through the 6 scenarios in M5_VERIFICATION.md:
+#
+#   Step 2 — happy path: 1 alert via WS → 2 deliveries (Alice fcm + telegram)
+#   Step 3 — 5 alerts, mixed severity + 30% dedupe → 10 deliveries
+#   Step 4 — bad API key (correct format, wrong secret) → 0 deliveries,
+#            ws_messages_total{result="bad_signature"} ticks
+#   Step 5 — per-IP concurrency cap (35 conns, cap=32) → 32 ok + 3 rejected,
+#            connection_rejected_total{transport="ws"} = 3
+#   Step 6 — live tail: subscribe then send → 1 frame arrives,
+#            tail_subscribers = 1 while connected
+#   Step 7 — live tail company filter: subscribe to globex-002 only →
+#            acme alerts filtered out, globex alert passes
+#
+# The script assumes the loadgen-ws binary is built at /tmp/loadgen-ws
+# (run `cd loadgen && go build -o /tmp/loadgen-ws ./cmd/ws`). The
+# per-IP cap test binary and tail driver are auto-built on first run;
+# they live in /tmp and are reused on subsequent runs.
+#
+# Exit code is the number of failed checks.
+
+set -e
+cd "$(dirname "$0")/.."
+
+PG="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
+INGESTD_METRICS=http://localhost:8800/metrics
+SRC_ACME=acme-001:prom-prod:s3cret-acme
+SRC_GLOBEX=globex-002:grafana:s3cret-globex
+WS_INGEST=ws://localhost:8800/v1/ingest/ws
+WS_TAIL=ws://localhost:8800/v1/tail/ws
+TAIL_TOKEN=tail-dev-token-please-change-in-prod
+
+fails=0
+pass() { echo "  ✅ $*"; }
+fail() { echo "  ❌ $*"; fails=$((fails+1)); }
+
+reset_state() {
+  $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL, telegram_invite_code = 'acme-bob-002' WHERE id = 'ind-acme-002';" >/dev/null
+  $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL WHERE id = 'ind-acme-003';" >/dev/null
+  $PG -c "UPDATE subscriptions SET min_severity = 'critical' WHERE individual_id = 'ind-acme-002' AND source_id = 'prom-prod';" >/dev/null
+  $PG -c "TRUNCATE deliveries;" >/dev/null
+  curl -sS -X POST http://localhost:8830/admin/reset >/dev/null
+  docker compose restart telegramd >/dev/null
+  for i in 1 2 3 4 5 6 7 8 9 10; do
+    if curl -sS http://localhost:8822/health 2>/dev/null | grep -q '"status":"ok"'; then
+      sleep 1
+      break
+    fi
+    sleep 1
+  done
+}
+
+ws_counter() {
+  # $1 = result label
+  curl -sS "$INGESTD_METRICS" 2>/dev/null | \
+    grep -E "^ba_ingestd_ws_messages_total\{result=\"$1\"" | \
+    awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
+}
+
+ws_conn() {
+  # $1 = state label
+  curl -sS "$INGESTD_METRICS" 2>/dev/null | \
+    grep -E "^ba_ingestd_ws_connections_total\{state=\"$1\"" | \
+    awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
+}
+
+ws_rej() {
+  curl -sS "$INGESTD_METRICS" 2>/dev/null | \
+    grep -E '^ba_ingestd_connection_rejected_total\{[^}]*transport="ws"[^}]*\}' | \
+    awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
+}
+
+tail_gauge() {
+  curl -sS "$INGESTD_METRICS" 2>/dev/null | \
+    grep -E '^ba_ingestd_tail_subscribers\{' | \
+    awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
+}
+
+# ── Setup: build loadgen-ws + per-IP-cap test binary + tail driver ──
+mkdir -p /tmp/m5_smoke
+if [[ ! -x /tmp/loadgen-ws ]]; then
+  echo "▸ Building /tmp/loadgen-ws"
+  (cd loadgen && CGO_ENABLED=0 go build -o /tmp/loadgen-ws ./cmd/ws)
+fi
+if [[ ! -x /tmp/m5-tail-test ]]; then
+  echo "▸ Building /tmp/m5-tail-test"
+  CGO_ENABLED=0 go build -o /tmp/m5-tail-test ./loadgen/cmd/m5drivers/tail
+fi
+if [[ ! -x /tmp/m5-perip-test ]]; then
+  echo "▸ Building /tmp/m5-perip-test"
+  cat > /tmp/m5_smoke/perip_test.go <<'GO'
+package main
+
+import (
+	"flag"
+	"fmt"
+	"sync"
+	"sync/atomic"
+
+	"git3.techno-world.net/lrosales/broad-announce/internal/wsclient"
+)
+
+func main() {
+	wsURL := flag.String("ws", "ws://localhost:8800/v1/ingest/ws", "ws endpoint")
+	apiKey := flag.String("key", "acme-001:prom-prod:s3cret-acme", "api key")
+	cap := flag.Int("cap", 35, "number of concurrent WS conns to attempt (cap=32 should reject 3)")
+	flag.Parse()
+
+	var ok, rejected atomic.Int64
+	var wg sync.WaitGroup
+	for i := 0; i < *cap; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			c, err := wsclient.Connect(wsclient.Config{URL: *wsURL, APIKey: *apiKey, DialTimeout: 5e9})
+			if err != nil {
+				rejected.Add(1)
+				return
+			}
+			ok.Add(1)
+			defer c.Close()
+		}()
+	}
+	wg.Wait()
+	fmt.Printf("ok=%d rejected=%d (cap=32 → ok<=32, rejected>=3 if cap=35)\n", ok.Load(), rejected.Load())
+}
+GO
+  mkdir -p ./scripts/m5_smoke_tmp
+  cp /tmp/m5_smoke/perip_test.go ./scripts/m5_smoke_tmp/main.go
+  CGO_ENABLED=0 go build -o /tmp/m5-perip-test ./scripts/m5_smoke_tmp
+  rm -rf ./scripts/m5_smoke_tmp
+fi
+
+# ─────────────────────────────────────────────────────────────────
+echo "── M5 smoke — WebSocket ingest + live tail + per-IP cap ──"
+echo ""
+
+reset_state
+
+# ── Step 2: happy path ────────────────────────────────────────
+echo "── Step 2: 1 alert via WS → 2 deliveries (Alice fcm + telegram) ──"
+acme_recv_before=$(ws_counter received)
+acme_acc_before=$(ws_counter accepted)
+deliveries_before=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
+acme_recv_before=${acme_recv_before:-0}
+acme_acc_before=${acme_acc_before:-0}
+deliveries_before=${deliveries_before:-0}
+
+/tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 1 --rate 1 2>&1 | tail -3
+sleep 2
+
+acme_recv_after=$(ws_counter received)
+acme_acc_after=$(ws_counter accepted)
+deliveries_after=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
+acme_recv_after=${acme_recv_after:-0}
+acme_acc_after=${acme_acc_after:-0}
+deliveries_after=${deliveries_after:-0}
+
+recv_delta=$((acme_recv_after - acme_recv_before))
+acc_delta=$((acme_acc_after - acme_acc_before))
+del_delta=$((deliveries_after - deliveries_before))
+
+if [[ $recv_delta -ge 1 ]]; then
+  pass "ws_messages_total{result=\"received\"} +$recv_delta"
+else
+  fail "ws_messages_total{result=\"received\"} delta was $recv_delta (expected ≥1)"
+fi
+if [[ $acc_delta -ge 1 ]]; then
+  pass "ws_messages_total{result=\"accepted\"} +$acc_delta"
+else
+  fail "ws_messages_total{result=\"accepted\"} delta was $acc_delta (expected ≥1)"
+fi
+if [[ $del_delta -ge 2 ]]; then
+  pass "deliveries +$del_delta (Alice fcm + telegram)"
+else
+  fail "deliveries delta was $del_delta (expected ≥2)"
+fi
+
+# ── Step 3: 5 alerts, mixed severity + 30% dedupe ───────────
+echo ""
+echo "── Step 3: 5 alerts, mixed severity + 30% dedupe → 10 deliveries ──"
+reset_state
+acme_recv_before=$(ws_counter received)
+acme_acc_before=$(ws_counter accepted)
+deliveries_before=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
+
+# Send 5 alerts at a steady rate. Default profile is
+# normal which uses 30% dedupe (i.e. ~2 of 5 alerts share
+# a dedupe_key), so we expect 3 new accepts and 2 dupes.
+/tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 5 --rate 5 2>&1 | tail -3
+sleep 3
+
+acme_recv_after=$(ws_counter received)
+acme_acc_after=$(ws_counter accepted)
+deliveries_after=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
+
+recv_delta=$((acme_recv_after - acme_recv_before))
+acc_delta=$((acme_acc_after - acme_acc_before))
+del_delta=$((deliveries_after - deliveries_before))
+
+if [[ $recv_delta -eq 5 ]]; then
+  pass "ws_messages_total{result=\"received\"} +$recv_delta"
+else
+  fail "ws_messages_total{result=\"received\"} delta was $recv_delta (expected 5)"
+fi
+if [[ $acc_delta -ge 3 ]]; then
+  pass "ws_messages_total{result=\"accepted\"} +$acc_delta (≥3 of 5 are new; rest deduped)"
+else
+  fail "ws_messages_total{result=\"accepted\"} delta was $acc_delta (expected ≥3)"
+fi
+if [[ $del_delta -ge 8 ]]; then
+  pass "deliveries +$del_delta (≥1 per non-deduped alert × 2 channels)"
+else
+  fail "deliveries delta was $del_delta (expected ≥8)"
+fi
+
+# ── Step 4: bad API key ─────────────────────────────────────
+echo ""
+echo "── Step 4: bad API key (wrong secret) → 0 deliveries, bad_signature counter ticks ──"
+reset_state
+bad_before=$(ws_counter bad_signature)
+deliveries_before=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
+
+# Use the right company:source (so auth passes) but wrong HMAC.
+cat > /tmp/m5_smoke/badkey.go <<'GO'
+package main
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+func main() {
+	conn, _, err := websocket.DefaultDialer.Dial("ws://localhost:8800/v1/ingest/ws", nil)
+	if err != nil { fmt.Println("dial err:", err); return }
+	defer conn.Close()
+	type Auth struct {
+		APIKey string `json:"api_key"`
+	}
+	if err := conn.WriteJSON(Auth{APIKey: "acme-001:prom-prod:s3cret-acme"}); err != nil {
+		fmt.Println("auth err:", err); return
+	}
+	conn.SetReadDeadline(time.Now().Add(2*time.Second))
+	_, authReply, err := conn.ReadMessage()
+	if err != nil { fmt.Println("auth reply err:", err); return }
+	fmt.Println("auth reply:", string(authReply))
+	body := map[string]any{"company_id":"acme-001","source_id":"prom-prod","severity":"info","title":"smoke-badkey","labels":map[string]string{"instance":"smoke-1"}}
+	bb, _ := json.Marshal(body)
+	// Sign with a wrong key — should fail HMAC verify.
+	mac := hmac.New(sha256.New, []byte("WRONG-SECRET"))
+	mac.Write(bb)
+	hexMac := hex.EncodeToString(mac.Sum(nil))
+	sig := "t=1700000000,v1=" + hexMac
+	env := map[string]any{
+		"alert": json.RawMessage(bb),
+		"auth":  sig,
+	}
+	if err := conn.WriteJSON(env); err != nil { fmt.Println("write err:", err); return }
+	conn.SetReadDeadline(time.Now().Add(2*time.Second))
+	_, ack2, err := conn.ReadMessage()
+	if err != nil { fmt.Println("ack2 err:", err); return }
+	fmt.Println("ack2:", string(ack2))
+}
+GO
+mkdir -p ./scripts/m5_smoke_tmp
+cp /tmp/m5_smoke/badkey.go ./scripts/m5_smoke_tmp/main.go
+CGO_ENABLED=0 go build -o /tmp/m5-badkey ./scripts/m5_smoke_tmp
+rm -rf ./scripts/m5_smoke_tmp
+
+/tmp/m5-badkey 2>&1 | tail -5
+sleep 2
+
+bad_after=$(ws_counter bad_signature)
+deliveries_after=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
+bad_delta=$((bad_after - bad_before))
+del_delta=$((deliveries_after - deliveries_before))
+
+if [[ $bad_delta -ge 1 ]]; then
+  pass "ws_messages_total{result=\"bad_signature\"} +$bad_delta"
+else
+  fail "ws_messages_total{result=\"bad_signature\"} delta was $bad_delta (expected ≥1)"
+fi
+if [[ $del_delta -eq 0 ]]; then
+  pass "no deliveries created"
+else
+  fail "deliveries delta was $del_delta (expected 0)"
+fi
+
+# ── Step 5: per-IP concurrency cap ─────────────────────────
+echo ""
+echo "── Step 5: per-IP cap (35 conns, cap=32) → 32 ok + 3 rejected ──"
+# Drain any residual per-IP count from prior tests. The per-IP
+# counter decrements on TCP close; some kernels buffer the
+# FIN/ACK and we can race the next test. A 5-second wait is
+# enough for the janitor to forget any stragglers (idle > 1s
+# in the test) and for the kernel to reap closed sockets.
+sleep 5
+rej_before=$(ws_rej)
+perip_out=$(/tmp/m5-perip-test --cap 35 2>&1 | tail -1)
+echo "  perip_test: $perip_out"
+sleep 1
+rej_after=$(ws_rej)
+rej_delta=$((rej_after - rej_before))
+
+if echo "$perip_out" | grep -qE "ok=3[0-2] rejected=[2-3]"; then
+  pass "perip_test reported 30-32 ok, 2-3 rejected (cap=32 held)"
+else
+  fail "perip_test output unexpected: $perip_out"
+fi
+if [[ $rej_delta -ge 2 && $rej_delta -le 3 ]]; then
+  pass "connection_rejected_total{transport=\"ws\"} +$rej_delta"
+else
+  fail "connection_rejected_total{transport=\"ws\"} delta was $rej_delta (expected 2-3)"
+fi
+sleep 1
+
+# ── Step 6: live tail, no filter ───────────────────────────
+echo ""
+echo "── Step 6: live tail (no filter) — subscribe then send 1 alert → 1 frame arrives ──"
+tail_before=$(tail_gauge)
+
+/tmp/m5-tail-test --target "$WS_TAIL" --token "$TAIL_TOKEN" --duration 6s 2>&1 > /tmp/m5_tail1.log &
+TAIL_PID=$!
+sleep 2  # let the tail subscribe
+tail_mid=$(tail_gauge)
+if [[ $tail_mid -ge 1 ]]; then
+  pass "tail_subscribers = $tail_mid while connected"
+else
+  fail "tail_subscribers was $tail_mid (expected ≥1)"
+fi
+
+/tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 1 --rate 1 2>&1 | tail -1
+wait $TAIL_PID 2>/dev/null
+
+if grep -q "^FRAME:" /tmp/m5_tail1.log; then
+  pass "tail received at least 1 frame"
+else
+  fail "tail received 0 frames (log: $(cat /tmp/m5_tail1.log))"
+fi
+
+tail_after=$(tail_gauge)
+if [[ $tail_after -eq 0 ]]; then
+  pass "tail_subscribers back to 0 after disconnect"
+else
+  fail "tail_subscribers was $tail_after after disconnect (expected 0)"
+fi
+
+# ── Step 7: live tail, company filter ─────────────────────
+echo ""
+echo "── Step 7: live tail (company=globex-002) — acme filtered, globex passes ──"
+
+/tmp/m5-tail-test --target "$WS_TAIL" --token "$TAIL_TOKEN" --company globex-002 --duration 6s 2>&1 > /tmp/m5_tail2.log &
+TAIL_PID=$!
+sleep 2
+
+# Send acme — should NOT reach the tail
+/tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 1 --rate 1 2>&1 | tail -1
+sleep 1
+# Send globex — should reach the tail
+/tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_GLOBEX" --count 1 --rate 1 2>&1 | tail -1
+wait $TAIL_PID 2>/dev/null
+
+# Count frames per company
+acme_frames=$(grep -c "company_id\":\"acme-001" /tmp/m5_tail2.log || true)
+globex_frames=$(grep -c "company_id\":\"globex-002" /tmp/m5_tail2.log || true)
+
+if [[ $acme_frames -eq 0 ]]; then
+  pass "0 acme frames reached globex-only tail (filter works)"
+else
+  fail "got $acme_frames acme frames on globex-only tail (expected 0)"
+fi
+if [[ $globex_frames -ge 1 ]]; then
+  pass "$globex_frames globex frame(s) reached the tail"
+else
+  fail "got $globex_frames globex frames (expected ≥1)"
+fi
+
+# ── Summary ─────────────────────────────────────────────────
+echo ""
+if [[ $fails -eq 0 ]]; then
+  echo "🟢 M5 smoke PASS — all checks green"
+  exit 0
+else
+  echo "🔴 M5 smoke FAIL — $fails check(s) failed"
+  exit $fails
+fi