# M2 Smoke Test — Live Results Run on 2026-06-13, host interserver2. All 7 steps from `M2_VERIFICATION.md` (plus a hard-fail scenario) passed against the running docker-compose stack. ## Step results | # | step | expected recipients | actual | result | |---|---|---|---|---| | 2 | warning, db-prod-04 | Alice only | ind-acme-001 | ✅ | | 3 | critical, db-prod-04 | Alice + Bob | ind-acme-001,ind-acme-002 | ✅ | | 4 | critical, db-prod-03 (rule match) | Alice + Bob | ind-acme-001,ind-acme-002 | ✅ | | 5 | warning, db-prod-03 (rule match but min filter) | Alice only (Bob filtered by min_severity) | ind-acme-001 | ✅ | | 6 | inminent_colapse, rack-b | Alice + Bob + Carol (Carol's quiet hours bypassed) | ind-acme-001,ind-acme-002,ind-acme-003 | ✅ | | 7 | info, with subscriptions paused | drop + log | `WARN msg="zero recipients, dropping"` | ✅ | Total: 9 deliveries across 5 unique alert_ids, 0 failures. ## What this proves about the rules engine 1. **`source.allowed_targets` is honored** — the resolver only considered individuals in `{sre group, ind-acme-003}`. Adding a 4th individual to the company with no subscription produced no deliveries. 2. **`routing_rules.match_expr` works** — the rule for `category=storage + data.host=db-prod-03 → Bob` was evaluated in step 4 (Bob would have been there anyway) and step 5 (Bob was added by the rule but then dropped by his min_severity filter). 3. **`subscriptions.min_severity` is enforced** — Bob's `min_severity=critical` filter dropped him from the warning alerts in steps 2 and 5. 4. **`subscriptions.quiet_hours` is enforced** — Carol's `00:00–23:59` window dropped her from every severity except `inminent_colapse` (step 6). 5. **`inminent_colapse` bypasses quiet hours** — Carol received the step 6 alert even though her quiet window was active. 6. **Hard-fail on zero recipients** — step 7 produced no delivery row and emitted a `WARN` log line with the full alert context (company, source, severity, category, alert_id). ## Performance - **Resolution latency**: not separately measured in M2. The routerd "routed" log line shows enqueue latency (publish to NATS), not resolver latency. Will be a Prometheus histogram in M9. - **DB round-trips per alert**: 1 (the CTE in `internal/routing/routing.go`). Comparable to M1's broadcast query. ## Test artifacts - `M2_VERIFICATION.md` — the 7-step test plan - `scripts/m2_smoke.sh` — automated end-to-end runner for steps 2-6 - `internal/routing/routing_test.go` — 10 subtests for the `inQuietHours` helper (same-day, wrap-around, always-quiet, edge cases) - `internal/alert/alert_test.go` — added `TestSeverityRank` and `TestMinSeverityRank` ## Honest flags - **Routing rule priority is "all matching rules win, unioned"**, not the full priority-ordered spec semantics. M2 SPEC §6 says "first match wins, unless continue=true". We union because: (a) the v1 use case is "all-storage-alerts go to sre" and "critical-only goes to on-call", which is naturally union, and (b) the M2 honest flag in PROMPT.md says priority semantics land in M3+. Will revisit when we add per-company on-call rotations. - **`routing_rules.match_expr` is intentionally simple**: `category`, `severity`, `data.=`, `all`. Full JSONPath/expression language is M6 or later. The SQL uses `EXISTS (SELECT 1 FROM jsonb_each(...))` so adding more `data.*` keys is free. - **M2 still reads HMAC secrets from `BA_INGESTD_SOURCES` env**, not the `sources` table. The `sources` table here holds *addressing* metadata (`allowed_targets`, `match_expr`); the auth path is unchanged from M0/M1. The M2 honest flag in PROMPT.md plans to move auth to the DB in M5 when mTLS and API-key paths come in. - **Subscription `channel_mask` accepts `telegram`** but the M2 resolver drops it (Q3 answer (a): emit the row, drop it). Since our seed only has `["fcm"]` in channel_mask, no rows are dropped in M2 verification. Adding `["telegram"]` to a subscription would result in a "no worker" situation; the SQL `WHERE f.channel = 'fcm'` filter is what enforces this. - **Resolver "broadcast fallback"**: when the source row exists, the SQL CTE `allowed_individual_ids` includes all active individuals in the company. This is the M2 "fall back to everyone" behavior, gated by whether the source row exists. Hard-fail (Q2 answer) kicks in only when the resolver returns zero targets AFTER this expansion — which requires the SQL filters (min_severity, quiet hours, channel_mask) to drop everyone, or no subscriptions to exist (step 7).