0009 — Rule definitions are snapshotted and versioned at fire time
Status: Accepted · 2026-08-07
Context
Section titled “Context”“We fetch the rule from Prometheus” is a 40-line HTTP client, not a moat. GET /api/v1/rules
returns expr, for, labels and annotations; Grafana already renders the rule query next to
the alert. As a differentiator it survives about a Tuesday.
What nobody ships is provenance over time: “this alert fired — show me the rule expression as it was at that moment, and how the threshold has moved since.” Grafana shows the current rule. Fetching the rule and versioning it upgrades a trivial HTTP client into a real capability, and it directly serves the alert-hygiene wedge.
Matching an alert instance back to its rule is genuinely ambiguous: alertname is not unique
across rule groups or files; alert_relabel_configs can rewrite it; rule labels: may contain
{{ $labels.x }} templates so a naive subset check fails; external_labels are added on the way
out so subset checks must be one-directional; and in a sharded setup the wrong Prometheus returns
nothing.
Decision
Section titled “Decision”Rule definitions are content-addressed and captured at fire time, then bound to the occurrence.
rule_fingerprint = sha256(expr || for || keep_firing_for || canon(labels) || canon(annotations))rule_key = (source_id, rule_file, rule_group, rule_name)rule_snapshots is UNIQUE on (org_id, source_id, rule_fingerprint).
alert_occurrences.rule_snapshot_id is the binding. Drift is “the snapshot bound to this
occurrence has a different rule_fingerprint than the one bound to the previous occurrence for
the same rule_key” → emit rule.definition_changed with a structured diff, and deliver a
rule_changed Slack thread reply regardless of channel verbosity.
Two acquisition paths, in order:
- Primary — parse
generatorURL. Itsg0.expris URL-encoded PromQL as evaluated. Zero API calls, zero ambiguity, and it works in multi-Prometheus setups.origin='generator_url'. - Enrichment —
GET /api/v1/rules?type=alert&rule_name[]=<alertname>&exclude_alerts=trueon the Prometheus identified bygeneratorURL’s scheme+host, forfor:,keep_firing_for:and the raw rule labels/annotations.origin='prometheus_api'. Cached aggressively.
Ambiguity (N>1 candidates) is scored on non-templated label subset plus annotation-key overlap
and recorded as match_confidence ∈ {exact, probable, ambiguous, none}. ambiguous is
surfaced in the UI and in Slack. It is never silently guessed.
duration and keepFiringFor are float seconds on the wire (600 means for: 10m).
Consequences
Section titled “Consequences”- oto answers “this alert fired because someone lowered the threshold from 90 % to 70 % two hours ago” — a support ticket resolved before it is opened. This is the strongest specific feature in the product.
- The alert timeline gains a genuinely new event type that no competitor emits.
- Snapshots are content-addressed, so a rule that never changes stores exactly one row across thousands of occurrences.
- Requires network reachability to Prometheus (optional per source). Without it,
originfalls back togenerator_url(expr only) orunavailable— degraded honestly, never faked. - oto is strictly read-only against Prometheus rules and will never become a rule editor.
Alternatives rejected
Section titled “Alternatives rejected”- Fetch the current rule on demand at render time: shows today’s rule next to yesterday’s alert. That is the bug this ADR exists to prevent.
- Match on
alertnamealone: wrong whenever two files define the same alert name, and silently so. - Store the whole
/api/v1/rulesresponse per occurrence: enormous duplication and no diffing primitive.