# Alerting on suspensions

How a guardrail suspension becomes a page — the anti-noise defaults, what a resolve does not claim, and why there is no YAML to write.

Source: https://tokentriage.com/docs/guardrails/alerting

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';

[Evidence & alerting](/docs/guardrails/evidence-and-alerting) says *alert on `suspended[]`*. This page
is how that actually happens, what you can tune, and — mostly — what you cannot.

## There is no `guardrails.alerts:` block

<Callout type="key">
**Guardrail alerting has no YAML surface of its own.** It is composed with defaults and reuses your
existing `alerts.channels`. There is no per-rule config binding, so a guardrail alert **fans out to
every configured alert destination**. Nothing in this page's contract is set from a config file; the
knobs below are composition defaults, and they are documented so you can predict the behavior rather
than change it.
</Callout>

The one thing you control is whether it is armed at all, and that is implied by two other decisions:

```console
{"level":"INFO","msg":"guardrail alerting armed","channels":0,"reused_dispatcher":true}
```

That line prints **only** when guardrails are enabled *and* the dashboard is composed. Both gates are
real:

- **`guardrails.enabled: false`** registers no alerting at all. Nothing to observe, nothing to latch.
- **The dashboard being off** means no channels, no shared dispatcher and no SSE publisher were
  built — so the wire is not made. A deployment running without the dashboard still gets guardrail
  suspensions **on its rows**, which is the durable record and is unconditional; it just gets no push
  notification.

## Where the signal comes from

The feed is the **ledger tee**, not a call from the request path. It fires *post-write*, in the
sink's own goroutine, after a successful append — so **an alert can never describe a row the file
does not contain.** Ingest is a copy-and-enqueue with drop-and-count, so a slow dispatcher can never
apply backpressure to the ledger writer.

A guardrail suspension is an **event, not a status.** There is no "is binding X suspended right now?"
to poll: the engine emits a suspension on the one request that faulted, and then forgets. So the
source latches the event itself — fire on the Nth observation, resolve after a quiet period.

## The anti-noise contract

| Knob | Default | What it does |
|---|---|---|
| `MinSuspensions` | **1** | How many suspensions for the **same** `(binding, phase, reason)` key before the alert fires. |
| `ResolveAfter` | **15m** | How long a firing key must go **without a new suspension** before it resolves. |
| `SweepInterval` | **1m** | The resolve cadence. It only bounds how late a resolve is *noticed*. |
| `QueueSize` | **4096** | The ingest queue bound, past which events are **dropped and counted** rather than blocking. |
| `DeliverTimeout` | **10s** | Bounds one delivery attempt. |

<Callout type="note">
**A floor of 1 is deliberate, not laziness.** Governance's rate rules floor on a minimum sample count
because a rate over three samples is noise. A guardrail suspension is not a rate — **it is an outage
report, and the first one is already true.**
</Callout>

The key includes the **scope** of the outage, which is what keeps the page accurate about the failure
mode: a binding that inspected *nothing* and a binding whose *one detector* fell over are different
keys and read differently. Inferring the scope from the detector map instead once produced a page for
a binding-wide gap that named a single detector — the wrong failure mode.

## What a resolve does not claim

<Callout type="warning">
A resolve says a suspension **stopped being reported**. On a deployment taking no traffic that is
**not** the same as a detector coming back — there are no rows, so there is nothing to report either
way. The resolved copy says exactly that rather than "restored", because an operator who reads
"restored" and stops looking is worse off than one who reads the truth.
</Callout>

## Nil-safe sinks, and `channels: 0`

Every sink is independently optional, and an absent one degrades rather than breaks:

- **No dispatcher** → no delivery. If you have no config alert rules at all, a private dispatcher is
  built so guardrail alerts still deliver, and closed again on shutdown.
- **No publisher** → no SSE publish.
- **`channels: 0`** → SSE only. **The source still latches and still reports `Firing`.** An armed
  line with zero channels is a working alert source with nowhere to send; the state is real even when
  the delivery is not.

## The one thing you cannot do with it

**Guardrail alerts are not backtestable, and the rule says so.** The raw fact is on
disk — every suspension is written to the row that produced it — but the backtest surface drives
rules compiled from config, and this is not one of those. Claiming otherwise would have the backtest
compute an empty result: *"0 would have fired"* over a window that contained a hundred suspensions is
worse than *"it cannot look."*

<NextSteps items={[
  { title: "Evidence & alerting", href: "/docs/guardrails/evidence-and-alerting", description: "The suspension vocabulary, and which reason implicates whom." },
  { title: "Guardrails API", href: "/docs/reference/guardrail-api", description: "Querying the evidence document directly, including the fired-on-nothing roster." },
  { title: "Roll out a guard without breaking traffic", href: "/docs/recipes/roll-out-a-guard-without-breaking-traffic", description: "The rollout ladder, with what to watch at each rung." }
]} />
