0070 — An alert class has one printed ID, and its fix history is derived from the PRs and the ledger, not kept

The problem

Smith fixes an alert, the alert comes back, and the next run fixes it again as if for the first time.

Three mechanisms make this the default rather than bad luck. The decider's resume-search reads only OPEN PRs on its branch prefix, so a merged attempt is invisible to the next run. A re-fire within 48h is skipped as a duplicate of the earlier verdict, so a recurrence after a fix is never looked at as a recurrence. And a person asking Slack "have we seen this?" has nothing to search for, because the class name lives in message metadata, not text.

What already existed (most of it)

The decision

  1. The ID is the class key, printed: <source> or <source>/<alert_class>. The emitter declares alert_class when source alone spans more than one problem (automation_run_failure/<jobId>). Smith derives it only for posts it does not author (sentry/<Short ID>). No registry: a new class gets an ID by having a source.
  2. One compose seam per runtime prints it. notify() in each runtime ends a marked alert with Alert ID: <id> · ALR-<8 hex>, and the marker payload carries alert_id. The ALR- alias is FNV-1a 32-bit of the id, identical in both runtimes and pinned by the same test vectors; it exists because Slack's tokenising of underscores could not be verified from the box. Hand-built CI cards add the same line. Until they do, Smith's router puts it on the first line of its thread reply.
  3. Every fix carries it. Verdicts print the ID. Fix PR bodies end with Alert-ID: <id>, so a human's fix counts as an attempt.
  4. Fix history is derived, never kept. At routing time Smith reads the PRs for the ID from the last 30 days, merged or closed, from every author: those on its branch prefix, or carrying its trailer. The bare prefix counts only for a non-coarse, sub-class-free ID; otherwise only the trailer counts. It also reads the last three Smith verdicts for the source from the ledger and the dedup window's repeat count. A fix merged before the current firing, outside the owning repo's deploy grace, did not hold. Attempts made before the ID was printed count.
  5. A recurrence after a fix is re-investigated, not deduped. When the source has a fix that merged before this firing, the 48h duplicate skip is bypassed: the alert goes to the decider marked "RECURRED after #N", and one line is threaded under the earlier verdict.
  6. The ladder. No failed attempt: as today. One: the verdict opens with why fix #N did not hold, with evidence, and the fix widens to the class. Two or more: no patch. The decider writes a design doc PR (docs/planning/alerts/<id>.md) naming every attempt and the bigger fix, and any human choice goes to the decisions page. "A different cause" may be argued with evidence. It does not reset the count. Both fix engines follow this.
  7. Rejected: a new store for attempts (two records of one fact, ADR-0020); failure-notes.md as the store; wisdom.json (its per-domain cap evicts); a hashed opaque ID alone (unreadable, and not the key everything already joins on).

Consequences