# 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.<key>=<value>`, `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).
