# 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.
