Skip to content
Back to skills

Alert Rule Lint

ASecurity

Per-rule alert lint: never-fire, missing for/runbook/severity, window vs scrape, Prom 3 breakage, routing — tool-proved.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythongojavaphpbashnodeexpressdockerkubernetesterraform

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill alert-rule-lint --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Alert Rule Lint?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Alert Rule Lint
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-alert-rule-lint/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-alert-rule-lint)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: alert-rule-lint
description: "Per-rule alert lint: never-fire, missing for/runbook/severity, window vs scrape, Prom 3 breakage, routing — tool-proved."
kind: skill
pack: observability
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: alert-rule-lint

## Premise

The worst alert is the one that never fires, and an empty query result looks exactly like a healthy service. A selector with a typo, a rate window of one scrape interval, an integer `le` on Prometheus 3, a 0/0 error ratio at zero traffic, `$value` in a label, a rule file no `rule_files` glob loads, a PrometheusRule the operator never selects, a `severity=critical` that the route tree sends to a receiver with no integrations — each of these passes review because the YAML reads fine. The second-worst pages on a one-sample blip, or after the incident is over.

This skill finds those defects in the rule, routing and monitor files a repo ships — Prometheus rule files, PrometheusRule CRDs, Helm-embedded groups, Mimir / Thanos / Loki ruler files, jsonnet mixins, Alertmanager and `AlertmanagerConfig`, Grafana-managed alerts and Datadog / New Relic / Honeycomb monitors — and proves each with a tool exit code. It owns `/obs-audit`'s Alert rules (§1–§6), Alert design (§7), Routing (§8) and Vendor monitors (§9) rows. `@observability-reviewer` (this pack) judges its rows; `alert-audit` (this pack) consumes them and keeps only the behavioural half — fire history, flapping, owner recency.

**Every finding cites the rule (alert name + `file:line`) + the condition that occurs (a sustained failure, a blip, a total outage, a label set the rules emit) + what the rule does then (silent, fires on the blip, routes to a null receiver) + the oracle that showed it + the fix in the rule's own source (the spec or mixin for generated rules, precedents § 42).** The unit is the rule, never the file: a file where one alert of two lacks a runbook is one finding on that alert. A grep hit or a tool message is a candidate; "alerting looks fine" is never printed — the output is the class-coverage block (§ Output).

**Tool first, grep second.** Config defects have deterministic, offline oracles: promtool, pint, amtool, mimirtool. `scripts/rule-inventory.py` (beside this file, installed as `.claude/skills/alert-rule-lint/scripts/rule-inventory.py`; Python 3 standard library, read-only) is the no-tool lane and the inventory every lane grounds against. Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/observability.md`](../../../../snippets/precedents/observability.md); the oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Observability, V203–V206.

## The rule

A candidate is a finding when all four hold (gates 1–7 of the protocol, read for alerts in `precedents/observability.md` § Reading the seven gates):

1. **The rule is loaded.** A `rule_files` glob matches its file, or the Prometheus CR `ruleSelector` selects its PrometheusRule, or the ruler namespace is synced. An unloaded rule is §4's AL-22 candidate — never dismissed as dead code.
2. **The condition occurs.** The failure the rule watches can happen at the rate and duration the expression assumes; the blip exists; traffic can drop to zero.
3. **Nothing already handles it.** A sibling traffic-absent rule (precedents § 5), a generator-emitted `absent` rule, a routes test proving every emitted label set (precedents § 9), a recording rule that smooths the expression.
4. **The oracle fired, on the server's version.** promtool, pint or amtool exited non-zero on the copy, or `rule-inventory.py` produced the row and a two-ended static trace holds (a rule never loaded, `$value` in labels, a selector absent from the inventory — `[report-flagged]` until a live `count()`). The Prometheus and Alertmanager versions are read from files, never assumed (gate 3).

## Boundaries

- **Fire history, flapping, dead-by-behaviour, owner recency** → `alert-audit` (this pack), L-tier with `NO-DATA(<source>)`. This skill never estimates a fire count.
- **Burn-rate arithmetic, SLO specs, regenerate-and-diff** → `slo-rule-scan` §2 (`burn-check.py`, `THR-OBS-BURN`). Here a multiwindow burn rule is recognised only so §1 does not demand a `for` on it (precedents § 2) and §4 still grounds its selectors.
- **Notification secrets inline** (Slack webhook, PagerDuty key) → security `secret-scan-method` when the security pack is installed; without it the row is tagged `secret (owner pack not installed)`, listed, never graded (precedents P2).
- **PrometheusRule / ServiceMonitor schema validity** (kubeconform with local CRD schemas) → infrastructure `k8s-api-version-scan` §2 when that pack is installed; without it the schema is unchecked and says so. This skill owns the PromQL content and whether the rule is *selected* (AL-22).
- **A CI job that runs promtool or pint but is neutered** (`continue-on-error`, `|| true`, path filters) → devops when that pack is installed; `/obs-audit` corroborates with its RULES GATE row.
- **Canary-analysis selectors** (Argo `AnalysisTemplate`) are devops's; they may cite `rule-inventory.py --check=selectors` for grounding.
- **Dashboards and the metric registration itself** → `telemetry-config-scan` §8 and `metric-hygiene-scan` (this pack).

## Adapt to the codebase — the rule lanes

Read each version from a file and print it in the output header: the Prometheus image tag (compose, Helm values, the Prometheus CR `spec.version`, the `kube-prometheus-stack` chart), the Alertmanager image, the pint version CI pins. A Prom-3-only class on an unread version is Unconfirmed (precedents § 17, § 45).

| Lane | Surfaces | Tool (T) | No-tool lane |
|---|---|---|---|
| Prometheus rule files | files matched by the `rule_files` globs in `prometheus.yml` | promtool (check + unit tests), pint | `rule-inventory.py` |
| Kubernetes | PrometheusRule CRDs, Helm `additionalPrometheusRules(Map)`, the Prometheus CR `ruleSelector` | promtool on the `--emit-rules` output; pint with the relaxed parser | `rule-inventory.py --prometheus-cr=` |
| Mimir / Thanos / Loki rulers | namespace rule files; LogQL rules | mimirtool `rules check` (its `rules lint` rewrites without `-n`) | `rule-inventory.py` (PromQL only) |
| jsonnet mixins | `mixin.libsonnet`, `prometheusAlerts` | render with jsonnet into the scratch dir, then the Prometheus lane; fix the source | the rendered file |
| Alertmanager | `alertmanager.yml`, `AlertmanagerConfig`, Helm `alertmanager.config` | amtool (check-config, the routes matrix) | `rule-inventory.py --am=` |
| Grafana-managed | provisioning `alerting/*.yaml`, `grafana_rule_group` | — | §9 read |
| Vendor monitors | `datadog_monitor`, `newrelic_nrql_alert_condition`, `honeycombio_trigger` | — | §9 read |

## Tool lane

Run every tool in `${TMPDIR:-/tmp}` on **copies**, pinned to the versions this skill was measured with (2026-10-10, darwin-arm64 release archives checked against the release `sha256sums.txt`): **promtool 3.15.0** (use **2.55.1** when the server is 2.x — the binary decides what parses), **amtool 0.34.1**, **pint 0.89.2**, **mimirtool 3.2.2**. Downloads come from each project's GitHub release page (`prometheus/prometheus`, `prometheus/alertmanager`, `cloudflare/pint`, `grafana/mimir`) into the scratch dir — never installed globally. A tool that cannot install prints `blocked (lane not installed: <tool>@<ver>)` in its coverage cell; `rule-inventory.py` still runs.

Exit codes are read, never assumed (precedents § 53): promtool 1 = error, 3 = lint (duplicates only with `--lint-fatal`); pint exits 1 when a problem at `--fail-on` (default `bug`) is found; amtool `routes test` **without** `--verify.receivers` exits 0 whatever it resolves (measured: `severity=critical` → `dev-null`, exit 0). Measured on the copies: `pint lint` left every file byte-identical; `mimirtool rules lint` without `-n` rewrote the file (added `namespace:`, re-indented) — so it never runs in the tree.

pint's default config runs few offline checks: on the seeded sample (§ Measured) it reported 4 problems. The rule-level checks need this config, written to the scratch dir (severities from `THR-OBS-ALERT`; replace the vocabulary with the one the routes match on):

```hcl
parser {
  relaxed = [".*"]
}
rule {
  match {
    kind = "alerting"
  }
  label "severity" {
    severity = "bug"
    value    = "(page|critical|ticket|warning|info|none)"
    required = true
  }
  annotation "summary" {
    severity = "warning"
    required = true
  }
}
rule {
  match {
    kind = "alerting"
    label "severity" {
      value = "(page|critical)"
    }
  }
  annotation "runbook_url" {
    severity = "bug"
    required = true
  }
  for {
    severity = "bug"
    min      = "1m"
    max      = "30m"
  }
}
```

With it pint 0.89.2 parsed the CRDs and the Helm values (`relaxed`), applied group-level `labels:`, and reported 15 bugs; 5 of them are `rule/for` on multiwindow burn rules (Pyrra ×2, Workbook ×3), refuted by precedents § 2 — read the `rule-inventory.py` row, which marks burn rules, before reporting a pint `rule/for` hit. pint also warns `alerts/comparison` on a Watchdog `vector(1)`: that is the intended heartbeat (§6), not a finding.

## Pre-flight

Run as **one** bash script from the repo root (`bash <<'EOF' … EOF`: macOS ships bash 3.2, so no `mapfile`; zsh does not split an unquoted `$line` into arguments, which silently turned every amtool case into one label). Every `rg` names its path; any rg exit 2 is a failed grep, never zero hits. Files-without-match is `--files-without-match` — never `rg -L`, which is `--follow`.

```bash
T="$(mktemp -d "${TMPDIR:-/tmp}/arl.XXXXXX")"; RI=.claude/skills/alert-rule-lint/scripts/rule-inventory.py
X=(-g '!**/node_modules/**' -g '!**/{dist,build,out,target,bin,obj,vendor,.venv,venv,__pycache__,coverage,.next,.nuxt,.output,generated,__generated__}/**'
   -g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata,playwright,cypress}/**'
   -g '!**/*.{spec,test,e2e,stories,story}.*' -g '!**/*_test.go' -g '!**/test_*.py' -g '!**/conftest.py' -g '!**/{docs,ai,.claude}/**' -g '!*.{md,mdx,snap,lock}' -g '!**/*.min.*')
# Rule surfaces keep generated/ (sloth and pyrra output is loaded, precedents § 42) and drop only vendored and test trees.
RX=(-g '*.{yml,yaml,jsonnet,libsonnet}' -g '!**/node_modules/**' -g '!**/{vendor,.git,charts/*/charts}/**' -g '!**/{test,tests,__tests__,fixtures,__fixtures__,testdata,e2e}/**' -g '!**/{docs,ai,.claude}/**')
CODE=(-g '*.{ts,tsx,js,mjs,cjs,py,go,java,kt,kts,cs,rb,php}'); NM='([A-Za-z_:][\w.:]*)'
# 0 versions (gate 3): image tags, the kube-prometheus-stack chart, the Prometheus CR (spec.version, ruleSelector).
rg -n --pcre2 '(?:prom/(?:prometheus|alertmanager)|prometheus/(?:prometheus|alertmanager)|cloudflare/pint|grafana/mimir)\S*:v?\d[\w.]*' -g '*.{tf,toml,json}' -g 'Dockerfile*' "${RX[@]}" -g '!**/package*.json' .
rg -n -A3 '\bkube-prometheus-stack\b' -g 'Chart.yaml' -g 'helmfile*.y*ml' -g '*.{tf,sh,bash,mk}' -g 'Makefile' -g '!**/node_modules/**' .   # a helm line with no --version pins nothing
PCR=(); while IFS= read -r f; do PCR+=("$f"); done < <(rg -l --pcre2 '^kind:\s*Prometheus\s*$' "${RX[@]}" .)
[ ${#PCR[@]} -gt 0 ] && rg -n -H --pcre2 '^\s+version:\s*\S+|^\s*(?:ruleSelector|ruleNamespaceSelector|serviceMonitorSelector):|^\s+(?:release|app\.kubernetes\.io/\S+):\s*\S+' "${PCR[@]}"
rg -n --pcre2 '\bruleSelectorNilUsesHelmValues\b|^\s*ruleSelector:' -g '*values*.y*ml' -g '!**/node_modules/**' .
# 1 rule files (§1–§7), rule_files globs (AL-22), scrape intervals (AL-12): prometheus.yml, ServiceMonitor/PodMonitor, Alloy.
RULE_FILES=(); while IFS= read -r f; do RULE_FILES+=("$f"); done < <( { rg -l --pcre2 '^\s*-?\s*(?:alert|record):\s*\S' "${RX[@]}" . ; rg -l --pcre2 '\bkind:\s*PrometheusRule\b|\badditionalPrometheusRules(?:Map)?:|\bprometheus(?:Alerts|Rules)\b' "${RX[@]}" . ; } | sort -u)
echo "rule files: ${#RULE_FILES[@]}"; [ ${#RULE_FILES[@]} -gt 0 ] && printf '  %s\n' "${RULE_FILES[@]}"
rg -n -A6 '^rule_files:' "${RX[@]}" .
rg -n --pcre2 '^\s*(?:scrape_interval|evaluation_interval):\s*\S+|\bscrape_interval\s*=\s*"\d+[smh]"' -g '*.{alloy,river}' "${RX[@]}" .
SM=(); while IFS= read -r f; do SM+=("$f"); done < <(rg -l --pcre2 '^kind:\s*(?:ServiceMonitor|PodMonitor|Probe)\s*$' "${RX[@]}" .)
[ ${#SM[@]} -gt 0 ] && rg -n -H --pcre2 '^\s+(?:-\s*)?interval:\s*\S+' "${SM[@]}"
# 2 the inventory (§4, precedents § 37, § 43): metric names registered in shipped code — prom-client and
#   @willsoto/nestjs-prometheus, prometheus_client, client_golang Opts, Micrometer, OTel meters, prometheus-net.
{ rg -U --pcre2 -o --no-filename -r '$1' "(?:new\s+(?:\w+\.)?(?:Counter|Gauge|Histogram|Summary)|make(?:Counter|Gauge|Histogram|Summary)Provider)\s*(?:<[^>]*>)?\(\s*\{[^}]*?\bname\s*:\s*['\"]${NM}['\"]" "${CODE[@]}" "${X[@]}" .
  rg -U --pcre2 -o --no-filename -r '$1' "(?:\bprometheus_client\.|(?<![.\w]))(?:Counter|Gauge|Histogram|Summary|Info|Enum)\(\s*['\"]${NM}['\"]" "${CODE[@]}" "${X[@]}" .
  rg -U --pcre2 -o --no-filename -r '$1' "(?:Counter|Gauge|Histogram|Summary)Opts\s*\{[^}]*?\bName:\s*\"${NM}\"" "${CODE[@]}" "${X[@]}" .
  rg -U --pcre2 -o --no-filename -r '$1' "(?:\b(?:Counter|Timer|Gauge|DistributionSummary|LongTaskTimer)\.builder|\bregistry\.(?:counter|timer|gauge|summary)|\b(?:create|Create)(?:Counter|Histogram|UpDownCounter|Gauge|Observable(?:Gauge|Counter|UpDownCounter))(?:<\w+>)?|\bcreate_(?:counter|histogram|up_down_counter|gauge|observable_\w+)|\b(?:Int64|Float64)(?:Counter|Histogram|UpDownCounter|Gauge|Observable(?:Counter|Gauge|UpDownCounter))|\bMetrics\.Create(?:Counter|Gauge|Histogram|Summary))\(\s*['\"]${NM}['\"]" "${CODE[@]}" "${X[@]}" .
} | sort -u > "$T/inventory.txt"; echo "inventory: $(wc -l < "$T/inventory.txt" | tr -d ' ') names → $T/inventory.txt"
# 3 routing (§8) and vendor monitors (§9).
rg -l --pcre2 '^\s*receivers:\s*$|\bkind:\s*AlertmanagerConfig\b' "${RX[@]}" .
rg -n --pcre2 '\b(?:noDataState|execErrState|no_data_state|exec_err_state)\b\s*[:=]\s*"?OK\b|\bresource\s+"(?:grafana_rule_group|datadog_monitor|newrelic_nrql_alert_condition|honeycombio_trigger|honeycombio_burn_alert)"' -g '*.{yml,yaml,json,tf}' -g '!**/node_modules/**' -g '!**/.terraform/**' .
# 4 the per-rule rows: pass the version and the configs the steps above printed (one --prometheus-config per server).
python3 "$RI" "${RULE_FILES[@]}" --format=text --inventory="$T/inventory.txt" --prom-major=<2|3 from step 0> \
  --prometheus-config=<prometheus.yml> --prometheus-cr=<prometheus CR file> --am=<alertmanager.yml or Helm values> \
  --emit-rules="$T/plain" --emit-am="$T/am.json"
python3 "$RI" "${RULE_FILES[@]}" --label-sets > "$T/label-sets.txt"
echo "scratch: $T"
```

`rule-inventory.py` flags: `--check=` takes `annotations,for,windows,selectors,prom3,value-in-labels,nan,absent,watchdog,expr,design` (default all); `--scrape=job=30s,*=15s` when intervals live outside a `prometheus.yml`; `--page-severities=` and `--severity-vocab=` when the routes use another vocabulary (`THR-OBS-ALERT`); `--group-labels=no` on a server that ignores them (precedents § 41); `--all` shows Hardening rows in text mode; `--self-test` proves the parser on the § 0.1 shapes (a commented `# runbook_url:`, a group-level `severity` on the next group, a block-scalar `expr`, a CRD, Pyrra and Workbook burn rules, a Watchdog). Exit 0 none, 1 a problem, 2 usage.

Then the tool lane on copies (each line prints `blocked (…)` when its binary is absent):

```bash
B="$T/bin"   # the four binaries downloaded into the scratch dir (§ Tool lane); pint.hcl written beside them
mkdir -p "$T/tree" && tar cf - "${RULE_FILES[@]}" | (cd "$T/tree" && tar xf -)
"$B/promtool" check rules --lint=all --lint-fatal <copied plain rule files> "$T"/plain/*.yml; echo "promtool exit $?"
"$B/pint" -c "$T/pint.hcl" --offline --no-color --show-duplicates lint --min-severity=info "$T/tree"; echo "pint exit $?"
"$B/amtool" check-config "$T/am.json"; echo "amtool exit $?"
while read -ra L; do [ "${L[0]}" = "(no" ] && L=(); printf '%-40s -> %s\n' "${L[*]:-<none>}" "$("$B/amtool" config routes test --config.file="$T/am.json" "${L[@]}" 2>/dev/null)"; done < "$T/label-sets.txt"
```

`--emit-am` writes the Alertmanager config — top level, or embedded in Helm values under `alertmanager.config` — as JSON amtool reads (quoted scalars stay text, plain ones are typed). Add the chart's own label sets by hand when it ships default rules (`alertname=Watchdog`, `severity=critical`, `severity=info`). The matrix prints the receiver each emitted label set reaches; every set that pages is then re-run with `--verify.receivers=<the receiver the design names>` (exit 1 on mismatch). A pager set that resolves to a receiver with no `*_configs` is AM-01.

### Measured (2026-10-10)

| Tree | Result |
|---|---|
| `benchmarks/fixtures/*` (15 fixtures) | 0 rule files, 0 inventory names, 0 alertmanager configs; the version and interval greps print nothing after two FP classes were removed (a `pubspec.yaml` `version:` and a compose healthcheck `interval:` — both greps are now scoped to Prometheus CR / ServiceMonitor files). No fixture ships alert rules: `obs-defects` (eval item 23) is the fixture this skill scores on |
| Seeded sample: 7 rule files, 35 alerts, 15 recording rules, Alertmanager, Prometheus CR, Helm values, code in TS / Python / Go / Java | pre-flight 0.14 s. Inventory 12 names, 0 from tests (a `*.spec.ts` registration is excluded); one FP class removed — the bare `Info(` alternative (prometheus_client) also matched Go `slog.Info("priced", …)`, so it is anchored to `prometheus_client.` or no receiver. `rule-inventory.py`: 34 H/M/L rows covering every seeded class (AL-01, -02, -03, -04, -05, -06, -08, -09, -10 ×2, -12, -13, -16, -17 ×2, -20, -21, -22 ×2, SY-01 ×2, SY-03, SY-04, AM-01, -03, -04, -06, -08); 0 H/M/L rows on the decoys (Pyrra 14/7/2/1, Workbook 3- and 4-tier burn rules without `for`, Watchdog `vector(1)`, a ticket without a runbook, `[1m]` on a 15s job, a 5xx ratio with a zero-traffic sibling) |
| Same sample, the research's corrected awk | 21 lines: it agrees on the page-tier misses but cannot tell tiers or burn rules apart (13 of its lines are precedents § 1 / § 2), and passes `for: 0s` |
| Same sample, promtool 3.15.0 / 2.55.1 | 3.15.0: all 5 plain files and 3 emitted CRD/Helm files parse, and a group-level `labels: {severity: page}` reaches the alert in `promtool test rules`. **2.55.1 rejects the whole file** (`field labels not found in type rulefmt.RuleGroup`) — on a 2.x server a group label is AL-22, not precedents § 41 |
| Same sample, pint 0.89.2 `--offline` | default config: 4 problems (AL-05, AL-06, an `alerts/comparison` warning on the Watchdog, a `promql/fragile` warning on a Workbook burn rule), and the CRD and Helm files fail to parse. With the config above: CRD and Helm parsed, 15 bugs + 5 warnings (`--show-duplicates`) — AL-01 ×4, AL-02, AL-03, AL-04 ×2, AL-05, AL-06, and 5 `rule/for` on burn rules (precedents § 2). Offline pint did not report AL-08, -10, -12, -13, -16, -17, -20, -21, -22 or SY-*: those rows come from `rule-inventory.py` |
| Same sample, mimirtool 3.2.2 | `rules check` exit 0 (it checks recording-rule naming: a `record: errorratio` exits 1); `rules lint -n` changed nothing |
| Same sample, promtool fire/blip pair | `rate(x[30s])` on a 30s series: the sustained-burst test gets `[]` (exactly 1× is empty on 3.x); with `[1m]` it fires. A no-`for` raw-counter page fires on a one-sample blip. A ratio with no traffic-absent sibling stays silent through a total outage |
| Real repository, read-only (a NestJS + Fastify monorepo, kube-prometheus-stack via Helm, one PrometheusRule CRD under `deploy/`) | pre-flight 0.76 s: 1 rule file (26 alerts, 21 recording rules, 3 groups), 0 hits in excluded trees, inventory 21 names; selector groundings: 21 to code, 18 to recording rules, 5 exporter defaults, 0 ungrounded. Versions: the Prometheus chart is installed by a script with no `--version`, so the Prometheus major is **unread** (gate 3). promtool 3.15.0 and 2.55.1 exit 0 on the emitted groups; pint (config above) 0 bugs, 33 warnings (`promql/regexp` on `route!~".*/health/.*"`, one `rule/dependency`). `rule-inventory.py`: 4 rows + 1 note → after reading: **AM-01 Refuted** (root `null`, but `--verify.receivers=telegram` exits 0 for page, ticket, warning and critical — precedents § 9); **AL-18 [report-flagged]** (the chart's Watchdog is routed to `null` and no heartbeat receiver exists, so a dead pipeline is silent); **AL-21 Unconfirmed** (one ticket rule uses `le="1"` where every other selector writes `le="0.1"` / `"0.3"`: empty on 3.x, clean on 2.x — settle by reading `prometheus_build_info`); **2 × AL-16 Hardening** (a job-failure ratio whose stopped-worker case a queue-backlog page covers; an outbound ratio where zero calls affect no one). Two script FPs found and fixed on this tree: a `/` inside `route!~".*/health/.*"` read as a division, and a `node_*` host counter ratio treated as traffic. `git status` clean after the run |
| `benchmarks/fixtures/obs-defects`, staged copy, step 4 filled from step 0 | 69 lines, 0.2 s; inventory 25 names; `rule-inventory.py` 30 H/M/L rows; 23/23 seeds; 0 H/M/L rows on the decoys. The fixture found one FP class, fixed: a black-formatted `Histogram(` with its name on the next line was not inventoried (the `prometheus_client` line ran without `-U`), so both selectors on it were false AL-10 [H]. The Micrometer / OTel line gained `-U` too. The seeded sample's inventory is unchanged (38 names) |

## The 3,000-hit path

1. **Intersect with what is loaded:** rule files a `rule_files` glob or `ruleSelector` picks up, AM configs a deployment mounts. An unloaded rule is AL-22's candidate, not dismissed.
2. **Group** by rule group, generator (sloth / pyrra / mixin source) and Alertmanager root: one generator defect is one finding with N rules (precedents P4, § 42).
3. **Rank:** page-tier rules first, then rules on critical SLOs and payment / auth / consumer paths, then tickets.
4. **Read the top 30 groups**, rule by rule.
5. **Run the bulk oracle:** one `pint --offline lint` and one `promtool test rules` pass over the copies settle whole groups; the routes matrix settles every label set at once.
6. **Ledger the rest** as `Live, unreviewed (k groups / n rules — rule-inventory.py <paths> --format=text)`. Never mark them `checked`.

## Scans for

| § | Classes | Tool rule / flag | Default |
|---|---|---|---|
| 1 | AL-01, AL-02, AL-09 — `for` absent, too long, `keep_firing_for` misuse | pint `rule/for`; `--check=for` | M page, L ticket |
| 2 | AL-03..AL-06 — severity, runbook / summary, a template label the aggregation dropped, `$value` in labels | pint `rule/label`, `alerts/annotation`, `alerts/template`; `--check=annotations,value-in-labels` | L–M; **H** for `$value` |
| 3 | AL-07, AL-08, AL-13, AL-14, AL-15, AL-19, AL-20 — expression cannot express the condition | pint `alerts/comparison`, `promql/counter`, `promql/rate`, `promql/fragile`; `--check=expr` | M |
| 4 | AL-10, AL-11, AL-22 — never fires: missing series, impossible PromQL, rule never loaded | `--check=selectors` + inventory; pint `promql/impossible`; online pint `promql/series` | **H** |
| 5 | AL-12, AL-21 — window vs scrape; Prometheus 3 breakage | `--check=windows,prom3` with the read version; `THR-OBS-RATE-WINDOW` | H (<2×), Hardening (2–4×) |
| 6 | AL-16, AL-17, AL-18 — NaN ratio, `absent` misuse, no dead-man's switch | pint `promql/nan`, `alerts/absent`; `--check=nan,absent,watchdog` | M–H |
| 7 | SY-01..SY-04 — cause pages, no ticket tier, mean latency, per-instance pages | `--check=design` | M |
| 8 | AM-01..AM-09 — routing, inhibition, timers, deprecated keys | amtool matrix; `--am=` | H / M / L |
| 9 | VM-01..VM-06 — Grafana and vendor no-data, recipients | pre-flight 3 + read | M–H |

### 1. `for` absent, too long, or `keep_firing_for` misuse (AL-01, AL-02, AL-09)

```
BAD:   - alert: OrdersFailing  expr: orders_failed_total > 100  (no for)   ·   for: 1h on a page   ·   keep_firing_for: 2h with for: 1h
GOOD:  for: 5m on a threshold page · no for on a Watchdog or a multiwindow burn rule · for of 2+ run cycles on batch staleness
```
**Read the code:** Is it a Watchdog or a burn rule whose short window debounces (precedents § 2; the row's expr has `and` across two windows)? Batch staleness set at two run cycles (§ 3)? A `predict_linear` page? Does `keep_firing_for` outlast the incident on a page? **Finding when** a page fires on one sample, or waits past `THR-OBS-ALERT.page_for_max` (30m) to fire. **Severity:** M page, L ticket. **Proof (V203):** promtool pair — a one-sample blip must stay silent, a sustained failure must fire within the page budget.

### 2. Severity, runbook, template labels, `$value` in labels (AL-03..AL-06)

```
BAD:   labels: { depth: "{{ $value }}" }   ·   sum by (job) (…) with summary "on {{ $labels.instance }}"   ·   a page with no runbook_url
GOOD:  $value in annotations only · the template reads labels the `by (…)` keeps · runbook_url on every page; summary on every alert
```
**Read the code:** Which vocabulary do the routes match on — `severity`, `priority`, `team` (read the AM config first)? Is a runbook injected at build time (a mixin `runbookURLPattern`)? Is the alert a ticket or info (precedents § 1: no runbook needed)? Does the server apply group labels (precedents § 41; a 2.x server rejects the file, § Measured)? **Finding when** a page has no runbook, an alert has no summary, a severity routes nowhere, a template renders empty, or `$value` sits in labels. **Severity:** L–M; H for `$value` (every new value is a new alert, so `for` never completes). **Proof (V203):** pint `alerts/annotation` / `alerts/template`, promtool `exp_annotations`.

### 3. The expression cannot express the condition (AL-07, AL-08, AL-13, AL-14, AL-15, AL-19, AL-20)

```
BAD:   orders_failed_total > 100 · rate(sum(x)[5m:1m]) · histogram_quantile(0.99, sum(rate(b[5m])) by (job)) · irate in a page · increase(x[1h]) == 3
GOOD:  increase(orders_failed_total[5m]) > 100 · sum(rate(x[5m])) · … by (job, le) · rate · round(increase(…)) >= 3
```
**Read the code:** Does a recording rule emit only bad series, so no comparison is needed? Is the counter compared through `changes()` / `resets()` on purpose? Native histograms (no `le`)? **Finding when** the expression is true when the system is healthy, never resolves, or cannot see a counter reset. **Severity:** M. **Proof (V204):** promtool — the healthy series must not fire, the failing one must.

### 4. Never fires: the series is missing, the PromQL impossible, or the rule never loaded (AL-10, AL-11, AL-22)

```
BAD:   http_request_duration_second_bucket (typo) · a rule file outside every rule_files glob · PrometheusRule release: kps vs ruleSelector release: monitoring
GOOD:  every selector resolves in the inventory (code, recording rules, exporter defaults) · every rule file matched · CR labels ⊇ ruleSelector.matchLabels
```
**Read the code:** Is the metric registered lazily, so it appears only after the first event (itself `metric-hygiene-scan` §2)? Is it an exporter default (`up`, `node_`, `kube_`, `probe_` — `exporter-default (unverified)` in the row: is that exporter deployed)? Is it an OTel name translated with unit and `_total` suffixes (precedents § 43, `translated [K?]`)? A client_golang name with a `Namespace` prefix (`suffix-only [K?]`)? Is the PrometheusRule in a namespace `ruleNamespaceSelector` covers? **Finding when** a page selector resolves in no inventory — **HIGH Unconfirmed** until `promtool query instant <url> 'count(<selector>)'` or online pint `promql/series` returns empty (precedents § 16: a promtool test with mocked series never proves a metric exists) — or the rule is never loaded (a two-ended static fact). **Severity:** H. **Proof (V203, V204):** the inventory diff plus the live `count()` (L), else `NO-DATA(prometheus)`; for AL-22, the glob or selector that misses.

### 5. Window under the scrape interval; Prometheus 3 breakage (AL-12, AL-21)

```
BAD:   rate(x{job="api"}[30s]) with api scraped every 30s · le="1" · [5m:5m] · holt_winters( — on a 3.x server
GOOD:  [1m]+ at 30s (≥ 2×, 4× recommended) · le="1.0" · [5m:1m] · double_exponential_smoothing with the feature flag
```
**Read the code:** Which scrape interval applies to this job (`prometheus.yml`, ServiceMonitor `interval`, Alloy)? Which major does the server run (§ Adapt)? **Finding when** the window is under `THR-OBS-RATE-WINDOW.min_multiple_of_scrape` (2×) — exactly 1× is empty on 3.x — or a 3.x-only breakage on a server proven to be 3.x (precedents § 45). **Severity:** H under 2×; 2–4× is Hardening (precedents § 6); on an unread version, Unconfirmed. **Proof (V204):** the promtool sustained-failure test on the server's major (§ Measured: `[30s]` → `[]`, `[1m]` fires).

### 6. NaN ratio, `absent` misuse, no dead-man's switch (AL-16, AL-17, AL-18)

```
BAD:   sum(rate(failed[5m])) / sum(rate(total[5m])) > 0.1 with no zero-traffic sibling · absent(up{job=~"api.*"}) with no for · no vector(1) Watchdog
GOOD:  a sibling `sum(rate(total[5m])) == 0` page for the same job · absent(up{job="api"}) with for: 5m · Watchdog → an external heartbeat receiver
```
**Read the code:** Does a sibling traffic-absent or zero-traffic rule exist for the same job or denominator metric, or does the SLO generator emit one (precedents § 5)? Does the Watchdog route to an external heartbeat (§8 matrix: `alertname=Watchdog`)? Is the evaluator managed (documented)? **Finding when** a total outage turns the ratio into NaN and nothing pages, `absent` fires on a restart or hides a missing target behind a regex, or no heartbeat proves the pipeline alive. **Severity:** M; H on a critical-path ratio page. **Proof (V204):** promtool total-outage test — `exp_alerts` must not be empty at zero traffic.

### 7. Alert design: cause pages, no ticket tier, mean latency, per-instance pages (SY-01..SY-04)

**Read the code:** Is the cause page definite and imminent — `predict_linear` disk-full within hours, certificate expiry before the next business day, quorum loss (precedents § 4)? Is the team one channel by design? Is the service a stateful singleton (per-instance is right)? Low traffic paging on one error is a design issue, not a rule bug (§ 15). **Finding when** a page fires on CPU, memory or a static disk threshold with no user-facing signal, every alert pages, a latency page uses `rate(_sum)/rate(_count)`, or N replicas give N pages. **Severity:** M (SY-03, SY-04: L). **Proof (V206):** the routes matrix for `severity=warning` / ticket sets; S for the rest.

### 8. Routing, inhibition, timers, deprecated keys (AM-01..AM-09)

```
BAD:   root receiver: dev-null (no *_configs) while pyrra emits severity=critical · inhibit equal: [cluster] that no rule emits · group_wait: 10m on a pager
GOOD:  every emitted {severity × team} proven by --verify.receivers · equal labels both sides carry · group_wait ≤ 5m, repeat ≥ 30m on a pager
```
**Read the code:** Which label sets do the rules actually emit (`--label-sets`, including group labels)? Is the null root intentional because every emitted set is proven elsewhere (precedents § 9)? Is `group_by: ['...']` on a self-deduping webhook (§ 8)? Deprecated `match` / `match_re` is Low, never bundled (§ 7). Inhibition and mute timing need a containerised Alertmanager (`amtool alert add` / `query`): without one they are `NO-DATA(alertmanager)`. **Finding when** an emitted page set reaches a null or dev receiver (H), an inhibit rule's `equal` labels are emitted by no rule so it mutes across every scope (H once proven live, M static), or timers after inheritance break `THR-OBS-ALERT` (M / L). **Proof (V206):** `amtool config routes test --verify.receivers=<expected> <labels>` exits 1; `check-config` clean.

### 9. Grafana-managed alerts and vendor monitors (VM-01..VM-06)

**Read the code:** Grafana `noDataState` / `execErrState: OK` on a page (VM-01), unless "keep last state" has a companion rule for datasource loss (precedents § 11). Datadog `notify_no_data` unset on a traffic or heartbeat monitor (VM-02; an error-count monitor is right, § 10), `message` with no `@` handle (VM-03). New Relic with no `expiration_duration`, or `fill_option = "last_value"` on an error rate (VM-04). Honeycomb trigger with no recipients, or an SLO with no burn alert (VM-05). Runbooks naming alerts no repo file defines (VM-06, L: export and diff). **Finding when** a signal that stops produces no alert in the tool that watches it. **Severity:** M–H. **Proof (V205):** the field check per monitor (S), an export diff (L).

## Proof

- **promtool fire / blip pair (V203, V204)**, in a copy, on the server's major. Each case states the correct behaviour, so a failure is the proof:

```yaml
rule_files: [../rules/api.yml]
evaluation_interval: 30s
tests:
  - interval: 30s                       # sustained failure: must fire
    input_series:
      - series: 'http_requests_total{job="api",code="503"}'
        values: '0+60x40'
    alert_rule_test:
      - eval_time: 15m
        alertname: ApiShortWindow
        exp_alerts: [{exp_labels: {severity: page, team: payments}, exp_annotations: {summary: 503 burst, runbook_url: "https://runbooks.example.internal/503"}}]
  - interval: 30s                       # one-sample blip: must stay silent
    input_series: [{series: 'orders_failed_total{job="api"}', values: '0 0 0 150 150 150'}]
    alert_rule_test: [{eval_time: 2m, alertname: OrdersFailing, exp_alerts: []}]
```

- **Total outage (AL-16):** every input series flat at 0; `exp_alerts` names the page that must fire. A test that passes on the unfixed rule proves nothing — rebuild it until it fails.
- **Routes matrix (V206):** `--verify.receivers` per emitted label set; exit 1 on mismatch.
- **Live series (L) for AL-10:** `count(<selector>) > 0` on the running server, only with a URL the user gave; else `NO-DATA(prometheus)` and the row stays HIGH Unconfirmed.
- A fix is accepted when the pair goes green on every rule of the group, the benign control stays silent, `rule-inventory.py` and pint print nothing for the touched rules, and `promtool check rules` exits 0. Never accepted: raising `for` past `THR-OBS-ALERT`, demoting a page to a ticket, deleting the rule or its test, `exp_alerts: []` added to make a test pass, a `# pint disable` comment, a route to `null`.

## Output

```
alert-rule-lint — Prometheus 3.5.0 (deploy/docker-compose.yml:3) · Alertmanager 0.28.1 · lanes: promtool 3.15.0, pint 0.89.2 (scratch .pint.hcl), amtool 0.34.1, rule-inventory.py · pint in CI: absent
Context: 7 rule files (5 plain, 1 CRD file with 2 docs, 1 Helm values) · rule_files: rules/*.yml · scrape 15s global, api 30s · inventory 12 names

Findings: 3 · Unconfirmed: 1

1. deploy/prometheus/rules/api.yml:82  ApiShortWindow                     [report-with-fix]   high
   §5 AL-12 — rate(…{job="api"}[30s]) with api scraped every 30s on Prometheus 3: the sustained-503 test gets [] (exactly 1× is empty).
   Proof: promtool test rules (3.15.0) — fails unfixed, passes with [1m]. Fix: [1m] (≥ 2×, THR-OBS-RATE-WINDOW).

2. deploy/alertmanager/routes.yml:2        root → dev-null                 [report-with-fix]   high
   §8 AM-01 — pyrra's ErrorBudgetBurn emits severity=critical and severity=warning; the routes match only page/ticket, so both resolve to dev-null.
   Proof: amtool config routes test --verify.receivers=pager severity=critical → exit 1. Fix: route on the generator's vocabulary (or set pyrra's severities), re-run the matrix.

3. deploy/prometheus/rules/api.yml:24  PaymentFailureRatio                [report-with-fix]   medium
   §6 AL-16 — ratio of rates with no zero-traffic sibling for payment_attempts_total: the total-outage test stays silent.
   Fix: a sibling `sum(rate(payment_attempts_total[5m])) == 0` page (for: 10m) for the same job.

4. deploy/prometheus/rules/api.yml:132  CheckoutLatency                    [report-flagged]    high
   §4 AL-10 — http_request_duration_second_bucket resolves in no inventory (code registers http_request_duration_seconds).
   Unproven link: the live series. Settling: promtool query instant <url> 'count(http_request_duration_second_bucket)'.

Verification: 34 candidates · 4 read → 3 findings · 1 unconfirmed · 30 Live, unreviewed (rule-inventory.py deploy --format=text) · 10 hardening

Class coverage:
  §1 for / keep_firing_for       Live, unreviewed (7 rows; pint rule/for 10 → 5 refuted: burn rules, precedents § 2)
  §2 severity / runbook / labels Live, unreviewed (6 rows; 10 ticket runbooks Hardening)
  §3 expression                  Live, unreviewed (3 rows)
  §4 never fires                 checked (4 rows → 1 unconfirmed; 3 rows Live, unreviewed) · live series NO-DATA(prometheus)
  §5 window / Prom 3             checked (2 rows → 1 finding; version read: 3.5.0)
  §6 NaN / absent / heartbeat    checked (3 rows → 1 finding; Watchdog present → heartbeat)
  §7 alert design                Live, unreviewed (4 rows)
  §8 routing                     checked (13 label sets; 3 reach dev-null → 1 finding) · inhibition NO-DATA(alertmanager)
  §9 vendor monitors             N/A (no Grafana-managed alerts, no monitor resources)
```

`[report-with-fix]`: the rule, the condition and a failing oracle are all present and the fix is in the rule's own source. `[report-flagged]`: one link is unproven (the live series, the server version, the deployed AM), named with its settling command. The coverage block lists **all nine** sections on every run: `checked (k → m)` only when the lane ran on present surfaces; `N/A (<reason>)` only when the repo has no such surface; `blocked (<reason>)` when a tool or grep could not run; `NO-DATA(<source>)` for an L-tier half; `Live, unreviewed` for rows not yet read.

## False positives / precedents

The verifier reads `precedents/observability.md` § 1–17 (alerts and routing), § 36–37 (verdict discipline), § 41–43 and § 45, § 53 (versions and tool exits). The ones this skill meets most:

- **A ticket or info alert without a runbook** (§ 1) and **no `for` on a Watchdog or multiwindow burn rule** (§ 2) — `rule-inventory.py` marks both; pint does not.
- **Burn factors** 14 / 7 / 2 / 1 (Pyrra) and the 3× 24h/2h ticket (Workbook final rule, sloth `google-30d`) are correct (§ 12, `THR-OBS-BURN`); the arithmetic is `slo-rule-scan`'s.
- **A NaN ratio with a sibling for the same job** (§ 5); **a window of 2–4× scrape** (§ 6); **integer `le` on a 2.x server** (§ 17).
- **A null root receiver** proven safe by a routes test for every emitted set (§ 9); **deprecated `match`** is Low (§ 7).
- **Generated rules** are judged at their spec; a defect in the rendered file is a drift finding (§ 42).
- **A selector naming no inventory entry** in a *proposed* fix is fabricated and Refuted (§ 37).

## When to run

- On any diff touching a rule file, a PrometheusRule, a Helm rules value, a mixin, an Alertmanager config, a Grafana alert or a monitor resource — `/obs-audit`'s dispatch signal.
- On every whole-repo `/obs-audit`, reporting all nine sections; before `alert-audit`, which consumes these rows.
- When `/alert-design` or `/add-telemetry` writes a rule: its gate is promtool + this skill's rows on the touched file.

## Halt conditions

- Do not report a high or critical row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 10. Under `/obs-audit` that happens in its verification phase; run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- Halt on any finding without the alert name, its `file:line`, the condition and the fix in the rule's source.
- Do not judge at file granularity: one row per rule. Never `rg -L` (it is `--follow`); never `--files-without-match` for a per-rule key.
- Do not report a Prometheus-3-only class without the server version read from a file; do not run promtool of a different major than the server and call the result proof.
- Do not call a selector "missing" from a promtool test with mocked series (precedents § 16), and do not promote AL-10 past Unconfirmed without a live `count()`.
- Never run `pint`, `mimirtool rules lint` or `lokitool rules lint` in the project tree; every tool runs on copies in `${TMPDIR}`. `git diff --exit-code` proves the tree untouched.
- Do not print "alerting is fine". Print the coverage block; a section whose lane did not run is `blocked`, never `checked`.
- Do not re-derive a class another skill owns (§ Boundaries). Cross-link it.

## Related

- `alert-audit` (this pack) — the behavioural L-tier half; reads this skill's rows and never re-greps rule files.
- `slo-rule-scan` (this pack) — burn arithmetic (`burn-check.py`) and regenerate-and-diff for generated rules.
- `@observability-reviewer` (this pack) — judges these rows in refute mode; `/obs-audit` dispatches, `/obs-fix` closes.
- `/alert-design`, `/add-telemetry` (this pack) — write rules; their alert gate is promtool plus these rows.
- `metric-hygiene-scan`, `telemetry-config-scan` (this pack) — the registrations the inventory reads, and dashboards.
- `secret-scan-method` (security pack, when installed), `k8s-api-version-scan` §2 (infrastructure pack, when installed), devops CI gates (when installed) — § Boundaries.

Files in this skill

  • SKILL.md40.9 KB
  • scripts/rule-inventory.py43.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.