Deterministic anomaly detection and scoped diagnosis
PLDeterministic anomaly detection means a versioned, unit-tested alert rule decides when production is unhealthy, and an AI agent only starts after that rule fires. The agent reads a frozen evidence bundle under a read-only identity and drafts a sourced diagnosis. It never decides whether an anomaly exists, and severity never grants it write access.
Your checkout error rate crept up for 40 minutes last Tuesday and nobody was paged, because the static “error rate above 5%” alert only fires on outages. The week before, the same alert paged twice at 03:00 for a service handling four requests a minute. Someone suggests letting an agent watch the dashboards instead. This page is for the developer who owns that alert and the tech lead who answers scorecard Q22 for the team: it shows how to make detection testable first, and where an agent adds value after that.
Scorecard question (Q22): How do you monitor production health and detect actionable anomalies?
Maximum-score answer: Version-controlled monitoring policy with tested thresholds, read-only diagnosis, evidence retention, and approval-tier routing
What a tested detection policy gives you
Section titled “What a tested detection policy gives you”- An SLO burn-rate alert rule for Prometheus, with a volume floor and a missing-telemetry alert, that you can adapt to your own service.
- A
promtoolunit-test file that replays four windows (normal, spike, sparse traffic, missing data) and goes red when someone weakens the threshold. - A diagnosis run for Claude Code, Codex, and Cursor that reads only an exported evidence bundle and writes an intake record for a human to accept.
- Three copy-paste prompts: draft rules and tests, diagnose a firing alert, and review a rule change.
- A failure-mode list and a checklist that proves the control works without anyone rereading the rules.
The broader monitoring stack (metrics, tracing, dashboards) lives in monitoring and observability. Live incident response, including a read-only Grafana MCP setup, lives in scoped AI incident triage. This page covers the step between them: the rule that fires and the first diagnosis.
Why the detector must not be a model
Section titled “Why the detector must not be a model”A detector has to give the same answer when you replay the same data. That property is what lets you unit-test it, review a change to it, and explain afterwards why it did or did not page. A model reading a dashboard gives a plausible answer that varies from run to run, so you cannot prove it would have caught last Tuesday’s incident. Split the work by what each part is good at:
| Layer | Decided by | Output | How you test it |
|---|---|---|---|
| Detection | A versioned rule evaluated by your monitoring system | Fires or does not fire | Replay unit tests in CI; same input, same decision |
| Routing | Labels on the rule (severity, risk_class) and the alert router | Who is paged, whether a diagnosis run starts | Router config tests, dry-run notifications |
| Diagnosis | A read-only agent over a frozen evidence bundle | Observations, hypotheses, missing evidence | Every claim cites a source a second pass can re-open |
| Action | A named human, through the deploy, flag, or rollback system | Mitigation, rollback, or a new intent | That system’s own audit log and approval gate |
The model’s narrative may differ between two runs on the same alert. That is acceptable, because the narrative is analysis. The trigger decision is not allowed to differ.
Write the alert rule as code
Section titled “Write the alert rule as code”Use the monitoring system you already run. The example below is a Prometheus rule file, because Prometheus rules have a first-party unit-test runner; Grafana Mimir and Thanos evaluate the same rule format. If you use Datadog, Grafana Cloud alerting, or CloudWatch, the same three ideas apply: keep the rule in the repository, attach routing labels to it, and replay history against it before merging.
The rule targets an SLO: 99.9% of checkout requests succeed over 30 days, so the error budget is 0.1%. It uses the multiwindow burn-rate pattern from the “Alerting on SLOs” chapter of Google’s SRE Workbook. A burn rate of 14.4 sustained for one hour spends 2% of a 30-day budget (14.4 × 1 h ÷ 720 h). The one-hour window stops a two-minute blip from paging; the five-minute window stops the alert from firing long after recovery.
groups: - name: checkout-slo rules: - record: checkout:error_ratio:rate5m expr: sum(rate(http_requests_total{service="checkout",code=~"5.."}[5m])) / sum(rate(http_requests_total{service="checkout"}[5m])) - record: checkout:error_ratio:rate1h expr: sum(rate(http_requests_total{service="checkout",code=~"5.."}[1h])) / sum(rate(http_requests_total{service="checkout"}[1h])) - record: checkout:requests:rate1h expr: sum(rate(http_requests_total{service="checkout"}[1h]))
# SLO: 99.9% of checkout requests succeed over 30 days (error budget 0.1%). # Page when the budget burns 14.4x too fast over 1h AND is still burning over 5m. - alert: CheckoutErrorBudgetFastBurn expr: | checkout:error_ratio:rate1h > (14.4 * 0.001) and checkout:error_ratio:rate5m > (14.4 * 0.001) and checkout:requests:rate1h > 1 labels: severity: page risk_class: low diagnosis: read-only annotations: summary: "Checkout is burning its 30-day error budget 14.4x too fast" runbook: "docs/runbooks/checkout-errors.md"
# Silence is not health: page if the checkout series disappears. - alert: CheckoutTelemetryMissing expr: absent(http_requests_total{service="checkout"}) for: 10m labels: severity: page risk_class: low diagnosis: read-only annotations: summary: "No checkout request metrics for 10 minutes" runbook: "docs/runbooks/telemetry-gap.md"Three details in that file do the work a static threshold cannot:
- The volume floor (
checkout:requests:rate1h > 1, one request per second) stops a service with four requests a minute from paging on two failures. - The
absent()alert catches the failure a ratio cannot see: when the exporter dies, the ratio has no data and the burn-rate alert stays silent. - The labels carry routing, not permissions.
risk_classuses the low, medium, high, and critical classes from governance and autonomy;diagnosis: read-onlytells the router to start a diagnosis run. No label mints a credential.
Pick the rule type from the shape of the signal instead of copying one ladder everywhere:
| Signal shape | Rule that fits | Why not a plain σ threshold |
|---|---|---|
| Request success against an SLO | Multiwindow burn rate | Pages on budget spent, which is what users feel |
| Strong daily or weekly cycle | Compare with the same hour on previous weeks (offset 1w) | A global mean flags every Monday morning |
| Sparse or bursty counts (payments per minute, sign-ups) | Minimum-event floor plus absolute threshold | Standard deviation of a near-zero series is noise |
| Latency | Percentile threshold with a for: persistence clause | Latency is skewed; mean and σ hide the tail |
| Heartbeat, cron, or exporter | absent() or absent_over_time() | No data produces no ratio at all |
Test the rule against replayed windows
Section titled “Test the rule against replayed windows”A rule you have not replayed is a guess. promtool test rules evaluates rules against synthetic series and compares the alerts that fire with the alerts you expect. The file below covers the four windows the scorecard asks about: normal, drift or spike, sparse, and missing data.
rule_files: - checkout-slo.rules.ymlevaluation_interval: 1m
tests: # Normal day: 0.05% errors at 100 req/s. Nothing fires. - interval: 1m input_series: - series: 'http_requests_total{service="checkout",code="200"}' values: '0+5997x120' - series: 'http_requests_total{service="checkout",code="500"}' values: '0+3x120' alert_rule_test: - eval_time: 90m alertname: CheckoutErrorBudgetFastBurn exp_alerts: []
# Spike: errors jump to 5% at minute 30. Fires once the 1h window catches up. - interval: 1m input_series: - series: 'http_requests_total{service="checkout",code="200"}' values: '0+5997x30 179910+5700x90' - series: 'http_requests_total{service="checkout",code="500"}' values: '0+3x30 90+300x90' alert_rule_test: - eval_time: 32m alertname: CheckoutErrorBudgetFastBurn exp_alerts: [] - eval_time: 60m alertname: CheckoutErrorBudgetFastBurn exp_alerts: - exp_labels: severity: page risk_class: low diagnosis: read-only exp_annotations: summary: "Checkout is burning its 30-day error budget 14.4x too fast" runbook: "docs/runbooks/checkout-errors.md"
# Sparse traffic: 1 of 2 requests per minute fails. Below the volume floor, no page. - interval: 1m input_series: - series: 'http_requests_total{service="checkout",code="200"}' values: '0+1x120' - series: 'http_requests_total{service="checkout",code="500"}' values: '0+1x120' alert_rule_test: - eval_time: 90m alertname: CheckoutErrorBudgetFastBurn exp_alerts: []
# Missing data: the series never arrives. The gap alert fires after 10 minutes. - interval: 1m input_series: [] alert_rule_test: - eval_time: 12m alertname: CheckoutTelemetryMissing exp_alerts: - exp_labels: service: checkout severity: page risk_class: low diagnosis: read-only exp_annotations: summary: "No checkout request metrics for 10 minutes" runbook: "docs/runbooks/telemetry-gap.md"Run both checks in CI on every change under monitoring/ (terminal or CI job, promtool 3.15.0):
promtool check rules monitoring/rules/*.rules.ymlcd monitoring/rules && promtool test rules --junit=promtool-junit.xml *.test.ymlBoth files above pass on promtool 3.15.0. To confirm the tests can fail, change 14.4 to 144 in the rule and run them again: the spike test goes red. Two details are easy to miss. absent() copies the equality matchers from its selector into the alert, so the missing-data alert carries service: checkout and the test must expect it. And the spike does not page at minute 32, because two minutes of errors barely move the one-hour ratio. That delay is the price of not paging on blips; decide it deliberately.
Let the agent draft rules and tests, not decide them
Section titled “Let the agent draft rules and tests, not decide them”Writing burn-rate arithmetic and synthetic series by hand is where an agent saves real time. The workflow is the same in Claude Code, Codex, and Cursor: open the repository, paste the prompt, and let the agent run promtool until the tests pass. The human review then reads the test cases and the thresholds, not the PromQL line by line.
Treat the result like any other agent pull request: the tests are the evidence, and the reviewer checks that they encode the behaviour the team wants. See reviewing an agent’s pull request without reading every line for that review.
Route a firing alert to a read-only diagnosis run
Section titled “Route a firing alert to a read-only diagnosis run”When CheckoutErrorBudgetFastBurn fires, the router pages a human and, because of the diagnosis: read-only label, starts a diagnosis run in parallel. The run never queries production itself. A small script exports a frozen evidence bundle first, so the agent works on the same data a human would audit later.
-
Send the alert to a webhook receiver in your alert router (for Prometheus, an Alertmanager route that matches
diagnosis="read-only"). The receiver saves the payload asalert.jsonin a fresh directory named after the alert fingerprint. -
Export the evidence with read-only credentials the runner holds, not the agent. For example, 60 minutes before and 30 minutes after the start of the alert:
Terminal window promtool query --http.config.file=/srv/diagnosis/prom-readonly.yml \range --start="$START" --end="$END" --step=1m \"$PROM_URL" 'checkout:error_ratio:rate5m' > "$EVIDENCE_DIR/error_ratio_5m.txt"prom-readonly.ymlis a promtool HTTP client configuration that holds the read-only bearer token (anauthorizationblock with acredentials_file), so the token never appears on the command line or in the evidence directory.Add the deploy events for the same window from your deploy system, and error-level logs with secrets and personal data masked at the source.
-
Run the agent over that directory with no write access: Claude Code gets only Read, Grep, and Glob; Codex gets a shell inside the read-only sandbox; Cursor gets a VM that holds only the evidence bundle. The tabs below show each run. It produces one intake file,
intake.md. -
Post the intake file to the incident channel through your existing bot. A person accepts, rejects, or escalates it; any rollback, flag change, or code fix goes through its own gate.
-
Store
alert.json, the evidence files, the intake file, and the run log with the incident record for the retention period your incident policy sets.
Run from the evidence directory (terminal or CI runner; checked on Claude Code 2.1.283 (latest) and 2.1.274 (stable)):
cd "$EVIDENCE_DIR"claude -p "$(cat /srv/diagnosis/diagnose-prompt.md)" \ --bare --restricted --setting-sources "" --strict-mcp-config \ --tools "Read,Grep,Glob" \ --allowedTools "Read,Grep,Glob" \ --permission-mode dontAsk \ --output-format json \ --max-budget-usd 2 > "$OUT_DIR/run.json" \ && jq -r .result "$OUT_DIR/run.json" > "$OUT_DIR/intake.md"--tools "Read,Grep,Glob" makes those three the only built-in tools, so there is no Bash, Edit, or Write, and --allowedTools pre-approves them explicitly. --restricted confines Read, Grep, and Glob to the working directory, so the agent can read the evidence directory and nothing else on the runner: no Codex auth, no cloud credentials in $HOME. Run the runner as a user that cannot read the service keys anyway, because a prompt injection in the logs can ask for any file it can reach, and step 4 posts the intake file to a channel. --bare skips hooks, plugins, and CLAUDE.md discovery, and authenticates with ANTHROPIC_API_KEY (or an apiKeyHelper passed via --settings), never OAuth or the keychain: give the runner a key owned by the incident service. --setting-sources "" loads no user, project, or local settings files, and --strict-mcp-config without --mcp-config starts no MCP servers, so nothing in the evidence directory or on the runner can add tools or permissions. dontAsk denies anything outside the allowed set instead of waiting for an approval nobody will give. run.json is the JSON envelope (result, cost, session ID) and serves as the run log that step 5 retains; jq extracts the Markdown record into intake.md.
Run with a service-owned CODEX_HOME for authentication (terminal or CI runner, codex-cli 0.157.1):
export CODEX_HOME=/srv/diagnosis/codexcodex exec --sandbox read-only --ignore-user-config --ignore-rules \ --ephemeral --skip-git-repo-check -C "$EVIDENCE_DIR" \ -o "$OUT_DIR/intake.md" \ "$(cat /srv/diagnosis/diagnose-prompt.md)"The read-only sandbox lets the model read files and run commands that cannot write. --ignore-user-config and --ignore-rules stop the run from loading anyone’s personal configuration. -o is written by the CLI, not by the model, so the intake file lands outside the sandbox. The read-only sandbox blocks writes, not reads: the model can still read any file on the disk that its user can read. Run it as an unprivileged user whose only readable files are the evidence bundle and its own CODEX_HOME.
Cursor Automations start a cloud agent from a schedule or an event, and the documented triggers include Webhook, Sentry, and PagerDuty (Cursor docs, checked 2026-08-28; cursor.com could not be re-checked on 2026-09-26). A webhook automation can receive the alert and run the same prompt, but the cloud VM does not see the evidence your runner exported in step 2, so the Cursor path needs one extra hand-off step. Either have the export script commit the bundle to a dedicated evidence repository (or branch) that the automation targets, or put the exported files, or a signed read-only URL to them, in the webhook payload. If the evidence arrives in the payload rather than as files in the checkout, change the first line of the prompt to say where it is.
A cloud agent runs in a full development VM with a shell, so read-only has to come from what the environment holds: give it the exported evidence bundle, no production, deploy, or cloud-console credentials, and have the automation deliver a report rather than a branch. Check the current trigger list and permission options in Cursor’s automations documentation before you rely on them.
Save this prompt as /srv/diagnosis/diagnose-prompt.md. It works unchanged in all three tools.
For a live session in which the agent queries Grafana, Sentry, or Datadog through MCP during the incident, use the read-only MCP configuration in scoped AI incident triage and the servers listed under observability MCP servers.
Review a change to the detection policy
Section titled “Review a change to the detection policy”Rules drift. Someone raises a threshold to stop a noisy page, and six weeks later a real regression goes unnoticed. Run this prompt on every pull request that touches monitoring/, and require the owning team’s approval as well.
Who decides what after an alert fires?
Section titled “Who decides what after an alert fires?”Severity changes who is paged. It never changes what the diagnosis identity can touch.
| Step | Agent’s role | Decided by | Recorded in |
|---|---|---|---|
| Rule fires | None | The versioned rule | Monitoring system, alert history |
| Page and start diagnosis | None | Alert router, from rule labels | Router log |
| Diagnose | Drafts the intake record | — | Intake file plus evidence bundle |
| Accept, reject, or escalate the intake | None | On-call engineer, or a declared policy for the low risk class only | Incident tool |
| Roll back, flip a flag, ship a fix | Names the runbook | A named human through that system’s gate | Deploy or flag audit log |
| Turn it into new work | Drafts intent.md | Triage owner | Backlog or repository |
For the gates on the last two rows, see production approval as a system boundary and rehearsed rollback with human production authority.
When anomaly detection fails
Section titled “When anomaly detection fails”Pages are still noisy after the rewrite. An agent summary does not fix a bad detector. Pull the last 30 days of pages, replay each firing window in a new test case, and tune the floor or window until the unwanted pages stop firing in the test before you change production.
A real incident did not page. Add the incident’s window to the test file as a case that must fire, watch it fail, then change the rule. That case now protects you from the next person who raises the threshold.
The metric disappeared and everything looked green. Every ratio-based alert needs an absent() companion. If the exporter is shared by several services, alert on each service’s series, not on the exporter as a whole.
Logs carry prompt injection. A log line or ticket body tells the agent to “silence this alert”. The diagnosis identity has no tool that can do it, and the prompt tells the agent to quote such text. Add a planted instruction to a test evidence bundle and check that the intake record quotes it and changes nothing.
Severity was wired to write access. Remove the coupling: a severity: critical label may page more people, but it must never start a run with deploy or database credentials.
The agent names a root cause from correlation. A deploy two minutes before the spike is a hypothesis, not a cause. Reject intake records that skip the counter-evidence or missing-evidence sections, and keep the audit prompt from scoped AI incident triage for high-risk incidents.
How to prove the control works
Section titled “How to prove the control works”- Every alert rule lives in the repository and changes only through a reviewed pull request.
-
promtool test rules(or your platform’s equivalent) runs in CI with normal, spike, sparse, and missing-data cases for each rule. - A deliberate threshold mutation makes at least one test fail.
- Replaying a historical incident window produces the same fire or no-fire decision, with no model involved.
- The diagnosis run cannot write outside its output file, can read only the evidence bundle, and holds no deploy or production credentials, and its identity shows up separately in audit logs.
- Alert payload, evidence bundle, intake record, and run log are retained with the incident.
- A dry run with a planted instruction in the logs changes nothing outside the intake file.
- Accepted intake records re-enter the lifecycle through triage, not as direct commits.
Where to go next with anomaly detection
Section titled “Where to go next with anomaly detection”Route accepted intake records to the Maintain stage, and measure detection and recovery with lifecycle metrics. To observe the agents themselves (tool calls, cost, audit trails), see agent observability.
Frequently asked questions
What is the role of an AI agent in deterministic anomaly detection?
A versioned, unit-tested alert rule decides when production is unhealthy, and the AI agent starts only after that rule fires to read a frozen evidence bundle under a read-only identity and draft a sourced diagnosis.
Does alert severity ever grant an anomaly-diagnosis agent write access to production?
No, the agent never decides whether an anomaly exists and alert severity never grants it write access or remediation authority in production.