Skip to main content

llm_assisted_api_debugging_lab/
diagnose.rs

1//! Deterministic rules engine.
2//!
3//! [`diagnose`] is a pure function over `(name, &[Evidence])`. There is no
4//! clock, no env, no fs, and no randomness — which is what makes the
5//! `insta` snapshot tests reproducible on any machine.
6//!
7//! ## How a rule works
8//!
9//! Each rule is a private function `rule_*(name, evidence, reproduction) ->
10//! Option<Diagnosis>`. The rule:
11//!
12//! 1. Inspects `evidence` for the variants it cares about. If the trigger
13//!    pattern is absent, the rule returns `None` and the dispatcher tries
14//!    the next rule.
15//! 2. If the trigger pattern is present, the rule calls [`pick`] to choose
16//!    which evidence items to surface in the rendered output (and in what
17//!    order).
18//! 3. The rule looks up its prose in `prose.toml` via [`prose`], then
19//!    constructs and returns a [`Diagnosis`].
20//!
21//! ## Why rule order matters
22//!
23//! Rules are tried in order from most specific (network-layer failure) to
24//! least specific (application-layer failure). The dispatcher returns the
25//! first match. This matters for inputs where multiple rules could in
26//! principle fire — see the test
27//! `tls_failure_rule_orders_after_dns_failure` for the canonical example.
28//!
29//! ## Why prose lives outside this file
30//!
31//! The hand-written English content (likely-cause templates, hypotheses,
32//! unknowns, next-steps, escalation notes, severity rationales) lives in
33//! `prose.toml` at the crate root. Editorial changes (a clearer
34//! hypothesis, a tighter escalation note) do not require a code change.
35//! Logic changes (severity, rule order, evidence patterns) still go here.
36
37use crate::evidence::Evidence;
38use crate::prose::prose;
39use serde::Serialize;
40
41/// Severity rank assigned to a diagnosis. See [`SeveritySource`] for how a
42/// rule arrived at the rank, and the README's "What it does (and does not)
43/// claim" section for the ranking philosophy (immediacy of failure, not
44/// blast radius).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum Severity {
48    Low,
49    Medium,
50    High,
51    Critical,
52}
53
54impl Severity {
55    /// Lowercase label used in rendered output.
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Severity::Low => "low",
59            Severity::Medium => "medium",
60            Severity::High => "high",
61            Severity::Critical => "critical",
62        }
63    }
64}
65
66/// How the rule arrived at its severity rank.
67///
68/// Today every rule reports `AuthorJudgment` because the diagnoser has no
69/// visibility into per-customer blast radius — it can only see one
70/// transaction at a time. The variant exists so the provenance is explicit
71/// in every rendered prompt and report; a reader cannot mistake a
72/// hand-assigned rank for a measured impact.
73///
74/// `DerivedFromEvidence` is reserved for a future variant that would
75/// derive severity from evidence values (e.g. `RetryAfterSecs > 60`
76/// upgrading rate-limit to High, or a sustained `ConnectionTimeout` count
77/// upgrading to Critical). When that variant gets used, the rendered
78/// label will say "derived from evidence" instead of "author judgment" —
79/// no caller code needs to change.
80#[derive(Debug, Clone, Serialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum SeveritySource {
83    AuthorJudgment { rationale: String },
84    DerivedFromEvidence { rationale: String },
85}
86
87impl SeveritySource {
88    /// The human-readable rationale, regardless of variant.
89    pub fn rationale(&self) -> &str {
90        match self {
91            SeveritySource::AuthorJudgment { rationale }
92            | SeveritySource::DerivedFromEvidence { rationale } => rationale,
93        }
94    }
95
96    /// Short label distinguishing the two provenance kinds; rendered next
97    /// to the severity rank in every output.
98    pub fn label(&self) -> &'static str {
99        match self {
100            SeveritySource::AuthorJudgment { .. } => "author judgment",
101            SeveritySource::DerivedFromEvidence { .. } => "derived from evidence",
102        }
103    }
104}
105
106/// The output of [`diagnose`], consumed by every renderer.
107///
108/// Fields are organized into three groups:
109///
110/// - **Identification:** `case`, `rule`. The `rule` is a stable string that
111///   names the rule arm that fired (e.g. `"dns_failure"`); it is the join
112///   key against `prose.toml`.
113/// - **Classification:** `severity`, `severity_source`, `likely_cause`. The
114///   `likely_cause` is human prose, possibly with a `{host}`/`{peer}`/`{field}`
115///   placeholder filled in. `severity_source` carries the provenance.
116/// - **Communication:** `evidence`, `hypotheses`, `unknowns`, `next_steps`,
117///   `reproduction`, `escalation_note`. These feed both the human report
118///   and the LLM prompt. The `evidence` field is a curated subset chosen
119///   by [`pick`] in rule order, not the raw input vector.
120///
121/// `Diagnosis` is `Serialize` so the JSON-envelope prompt renderer can
122/// embed it directly without a parallel struct.
123#[derive(Debug, Clone, Serialize)]
124pub struct Diagnosis {
125    pub case: String,
126    pub severity: Severity,
127    pub severity_source: SeveritySource,
128    pub likely_cause: String,
129    pub evidence: Vec<Evidence>,
130    pub hypotheses: Vec<String>,
131    pub unknowns: Vec<String>,
132    pub next_steps: Vec<String>,
133    pub reproduction: String,
134    pub escalation_note: String,
135    pub rule: &'static str,
136}
137
138/// Diagnose a case by name and the evidence collected for it.
139///
140/// Rules are matched in a fixed order from most specific (network-layer)
141/// to least specific (application-layer); the first matching rule wins.
142/// The `unknown` fallback always returns a diagnosis — an unrecognized
143/// pattern produces an explicit "no rule matched" diagnosis rather than a
144/// silent guess.
145///
146/// Order is documented in `docs/architecture.md` and pinned by both unit
147/// tests (e.g. `dns_failure_wins_over_other_signals`) and proptest
148/// invariants (`tests/proptests.rs` proves selection is permutation- and
149/// rotation-invariant for any input).
150///
151/// The `name` parameter is used only for the rendered output (CASE label,
152/// reproduction command). Rule selection itself is a pure function of
153/// `evidence`.
154pub fn diagnose(name: &str, evidence: &[Evidence]) -> Diagnosis {
155    // The reproduction command is the same for every rule arm — compute it
156    // once and pass by reference.
157    let reproduction = format!("cargo run -p llm-assisted-api-debugging-lab -- diagnose {name}");
158
159    // Rule order: network layer first, then transport, then application.
160    // Each rule's trigger pattern is documented in its function body.
161    if let Some(d) = rule_dns_failure(name, evidence, &reproduction) {
162        return d;
163    }
164    if let Some(d) = rule_tls_failure(name, evidence, &reproduction) {
165        return d;
166    }
167    if let Some(d) = rule_connection_timeout(name, evidence, &reproduction) {
168        return d;
169    }
170    if let Some(d) = rule_webhook_signature(name, evidence, &reproduction) {
171        return d;
172    }
173    if let Some(d) = rule_rate_limit(name, evidence, &reproduction) {
174        return d;
175    }
176    if let Some(d) = rule_auth_missing(name, evidence, &reproduction) {
177        return d;
178    }
179    if let Some(d) = rule_bad_payload(name, evidence, &reproduction) {
180        return d;
181    }
182    // Fallback always fires. By design it returns `Diagnosis`, not
183    // `Option<Diagnosis>`, so the dispatcher can never return `None`.
184    rule_unknown(name, evidence, &reproduction)
185}
186
187/// Construct a `Diagnosis` from the pieces that vary between rules.
188///
189/// Every rule arm computes the same shape: a severity rank, a curated
190/// evidence list, a `likely_cause` string. Everything else (severity
191/// rationale, hypotheses, unknowns, next-steps, escalation note) is
192/// pulled from `prose.toml` keyed on the rule name. This helper performs
193/// that lookup and assembles the `Diagnosis` so each rule arm can stay
194/// focused on the parts that are actually rule-specific.
195///
196/// `rule` doubles as the prose-table key and the `Diagnosis::rule`
197/// field, which means a typo in one place is impossible — there is only
198/// one place to put it.
199fn from_rule(
200    name: &str,
201    rule: &'static str,
202    severity: Severity,
203    likely_cause: String,
204    pinned_evidence: Vec<Evidence>,
205    reproduction: &str,
206) -> Diagnosis {
207    let p = prose().rule(rule);
208    Diagnosis {
209        case: name.into(),
210        severity,
211        severity_source: SeveritySource::AuthorJudgment {
212            rationale: p.severity_rationale.clone(),
213        },
214        likely_cause,
215        evidence: pinned_evidence,
216        hypotheses: p.hypotheses.clone(),
217        unknowns: p.unknowns.clone(),
218        next_steps: p.next_steps.clone(),
219        reproduction: reproduction.into(),
220        escalation_note: p.escalation_note.clone(),
221        rule,
222    }
223}
224
225/// Trigger: any `DnsResolutionFailed` evidence item.
226///
227/// Severity Critical: name resolution failed before any traffic was sent,
228/// so no application-level diagnosis is possible until DNS is restored.
229/// Ordered first because every other failure mode presupposes that DNS
230/// resolved.
231///
232/// Pinned evidence order: DNS first (the cause), then `ConnectionTimeout`
233/// or `HttpStatus` if either is present (rare in practice — both imply DNS
234/// did resolve, but evidence collected from the response side could
235/// theoretically include them alongside a context-derived DNS signal).
236fn rule_dns_failure(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
237    // Extract just the host; the message is rendered via the prose
238    // template's `{host}` substitution, not directly.
239    let host = ev.iter().find_map(|e| match e {
240        Evidence::DnsResolutionFailed { host, .. } => Some(host.clone()),
241        _ => None,
242    })?;
243    let pinned = pick(
244        ev,
245        &[
246            |e| matches!(e, Evidence::DnsResolutionFailed { .. }),
247            |e| matches!(e, Evidence::ConnectionTimeout { .. }),
248            |e| matches!(e, Evidence::HttpStatus(_)),
249        ],
250    );
251    let likely_cause = prose().rule("dns_failure").likely_cause_with_host(&host);
252    Some(from_rule(
253        name,
254        "dns_failure",
255        Severity::Critical,
256        likely_cause,
257        pinned,
258        reproduction,
259    ))
260}
261
262/// Trigger: any `TlsHandshakeFailed` evidence item.
263///
264/// Severity Critical: TLS failure means no HTTP request was ever
265/// transmitted. Like DNS failure, until the transport is restored there
266/// is no application-level evidence to reason about.
267///
268/// Ordered after `dns_failure` because TLS presupposes DNS resolved (a
269/// host that doesn't resolve cannot have started a TLS handshake). The
270/// test `tls_failure_rule_orders_after_dns_failure` pins this — when both
271/// kinds of evidence are present, DNS wins.
272fn rule_tls_failure(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
273    // Extract the peer hostname; the reason is rendered via the prose
274    // template's `{peer}` substitution.
275    let peer = ev.iter().find_map(|e| match e {
276        Evidence::TlsHandshakeFailed { peer, .. } => Some(peer.clone()),
277        _ => None,
278    })?;
279    let pinned = pick(
280        ev,
281        &[
282            |e| matches!(e, Evidence::TlsHandshakeFailed { .. }),
283            |e| matches!(e, Evidence::DnsResolutionFailed { .. }),
284            |e| matches!(e, Evidence::HttpStatus(_)),
285        ],
286    );
287    let likely_cause = prose().rule("tls_failure").likely_cause_with_peer(&peer);
288    Some(from_rule(
289        name,
290        "tls_failure",
291        Severity::Critical,
292        likely_cause,
293        pinned,
294        reproduction,
295    ))
296}
297
298/// Trigger: any `ConnectionTimeout` evidence item.
299///
300/// Severity High: the client aborted before any HTTP response was
301/// received. The transport opened and the request went out, but no reply
302/// came back inside the budget. Severity is High rather than Critical
303/// because evidence at this layer still allows the on-call to correlate
304/// the failed `request_id` with server-side traces (whereas DNS/TLS
305/// failures provide nothing past the transport layer).
306fn rule_connection_timeout(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
307    if !ev
308        .iter()
309        .any(|e| matches!(e, Evidence::ConnectionTimeout { .. }))
310    {
311        return None;
312    }
313    let pinned = pick(
314        ev,
315        &[
316            |e| matches!(e, Evidence::ConnectionTimeout { .. }),
317            |e| matches!(e, Evidence::HttpStatus(_)),
318        ],
319    );
320    let likely_cause = prose()
321        .rule("connection_timeout")
322        .likely_cause_static()
323        .to_string();
324    Some(from_rule(
325        name,
326        "connection_timeout",
327        Severity::High,
328        likely_cause,
329        pinned,
330        reproduction,
331    ))
332}
333
334/// Trigger: any `SignatureMismatch` evidence item.
335///
336/// Severity High: HMAC verification rejected the inbound webhook. The
337/// failure is silent in the sense that there is no surfaced error in the
338/// customer's application code — the receiver just stops processing
339/// events. That class of failure tends to go unnoticed for long stretches.
340///
341/// Pinned evidence ordering walks the reader through the chain: the HTTP
342/// status (the symptom), then the signature mismatch (the diagnoser's
343/// observation), then the clock drift and body-mutation signals if
344/// present (the two most common upstream causes).
345fn rule_webhook_signature(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
346    if !ev.iter().any(|e| matches!(e, Evidence::SignatureMismatch)) {
347        return None;
348    }
349    let pinned = pick(
350        ev,
351        &[
352            |e| matches!(e, Evidence::HttpStatus(_)),
353            |e| matches!(e, Evidence::SignatureMismatch),
354            |e| matches!(e, Evidence::ClockDriftSecs { .. }),
355            |e| matches!(e, Evidence::BodyMutatedBeforeVerification),
356        ],
357    );
358    let likely_cause = prose()
359        .rule("webhook_signature")
360        .likely_cause_static()
361        .to_string();
362    Some(from_rule(
363        name,
364        "webhook_signature",
365        Severity::High,
366        likely_cause,
367        pinned,
368        reproduction,
369    ))
370}
371
372/// Trigger: `HttpStatus(429)` AND `RetryAfterSecs(_)` together.
373///
374/// Severity Medium: this is expected back-pressure rather than a service
375/// fault. The server explicitly told the client to slow down; the client's
376/// retry path is the right place to handle it.
377///
378/// Both signals are required by design. A bare 429 without `Retry-After`
379/// is unusual enough that we'd rather fall through to `unknown` than
380/// guess; the test `rate_limit_rule_requires_429_and_retry_after` pins
381/// this.
382fn rule_rate_limit(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
383    let has_429 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(429)));
384    let has_retry = ev.iter().any(|e| matches!(e, Evidence::RetryAfterSecs(_)));
385    if !(has_429 && has_retry) {
386        return None;
387    }
388    let pinned = pick(
389        ev,
390        &[
391            |e| matches!(e, Evidence::HttpStatus(_)),
392            |e| matches!(e, Evidence::RetryAfterSecs(_)),
393            |e| matches!(e, Evidence::RateLimitObserved { .. }),
394        ],
395    );
396    let likely_cause = prose().rule("rate_limit").likely_cause_static().to_string();
397    Some(from_rule(
398        name,
399        "rate_limit",
400        Severity::Medium,
401        likely_cause,
402        pinned,
403        reproduction,
404    ))
405}
406
407/// Trigger: `HttpStatus(401)` AND `HeaderMissing { name: "Authorization" }`
408/// together.
409///
410/// Severity Medium: the request was rejected at the auth boundary, but
411/// the failure mode is well-understood and almost always a client-side
412/// configuration issue (env var unset, secret manager not loaded, proxy
413/// stripping the header).
414///
415/// Both signals are required: a bare 401 without missing Authorization
416/// might be a wrong key, an expired token, or many other things that this
417/// rule cannot distinguish. The conjunctive trigger keeps the diagnosis
418/// honest.
419fn rule_auth_missing(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
420    let has_401 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(401)));
421    let auth_missing = ev
422        .iter()
423        .any(|e| matches!(e, Evidence::HeaderMissing { name } if name == "Authorization"));
424    if !(has_401 && auth_missing) {
425        return None;
426    }
427    let pinned = pick(
428        ev,
429        &[
430            |e| matches!(e, Evidence::HttpStatus(_)),
431            |e| matches!(e, Evidence::HeaderMissing { .. }),
432        ],
433    );
434    let likely_cause = prose()
435        .rule("auth_missing")
436        .likely_cause_static()
437        .to_string();
438    Some(from_rule(
439        name,
440        "auth_missing",
441        Severity::Medium,
442        likely_cause,
443        pinned,
444        reproduction,
445    ))
446}
447
448/// Trigger: `HttpStatus(400)` AND `JsonValidationError { .. }` together.
449///
450/// Severity Low: a single client-side request failed with a structured
451/// validation response. The client has all the information it needs to
452/// fix the request and retry; nothing else is affected.
453///
454/// The `field` value (the name of the failing field, if known) is
455/// surfaced through the prose template's `{field}` placeholder. If the
456/// validation evidence carries no field name, the no-field template is
457/// used instead — see `RuleProse::likely_cause_with_optional_field`.
458fn rule_bad_payload(name: &str, ev: &[Evidence], reproduction: &str) -> Option<Diagnosis> {
459    let has_400 = ev.iter().any(|e| matches!(e, Evidence::HttpStatus(400)));
460    let validation = ev.iter().find_map(|e| match e {
461        Evidence::JsonValidationError { field, .. } => Some(field.clone()),
462        _ => None,
463    });
464    if !has_400 || validation.is_none() {
465        return None;
466    }
467    // `validation` is `Option<Option<String>>`: the outer is "did we find a
468    // JsonValidationError at all", the inner is "did that error name a
469    // field". Flatten to drop the outer Some(...) wrapper.
470    let field = validation.flatten();
471    let pinned = pick(
472        ev,
473        &[
474            |e| matches!(e, Evidence::HttpStatus(_)),
475            |e| matches!(e, Evidence::JsonValidationError { .. }),
476        ],
477    );
478    let likely_cause = prose()
479        .rule("bad_payload")
480        .likely_cause_with_optional_field(field.as_deref());
481    Some(from_rule(
482        name,
483        "bad_payload",
484        Severity::Low,
485        likely_cause,
486        pinned,
487        reproduction,
488    ))
489}
490
491/// Fallback rule: always fires.
492///
493/// This is the architectural promise that the diagnoser will not silently
494/// guess. If no other rule matched, this one produces a diagnosis whose
495/// `likely_cause` literally says "Evidence does not match any built-in
496/// rule" and whose next-steps tell the reader to add a fixture and a
497/// rule for this evidence shape before claiming a diagnosis. Severity
498/// Low because the diagnoser has nothing to base a higher rank on, not
499/// because the underlying failure is benign — the prose's
500/// `severity_rationale` says exactly this.
501///
502/// Returns `Diagnosis` (not `Option<Diagnosis>`): this is the dispatcher's
503/// guarantee that `diagnose()` always returns a value.
504fn rule_unknown(name: &str, ev: &[Evidence], reproduction: &str) -> Diagnosis {
505    let likely_cause = prose().rule("unknown").likely_cause_static().to_string();
506    // The fallback shows every evidence item it received; it has no
507    // basis for curating further. Compare against every other rule,
508    // which calls `pick()` to surface a curated subset.
509    from_rule(
510        name,
511        "unknown",
512        Severity::Low,
513        likely_cause,
514        ev.to_vec(),
515        reproduction,
516    )
517}
518
519/// Pick evidence items matching the given predicates, in the order the
520/// predicates appear, preserving original ordering for ties. Used by
521/// every rule arm to choose which evidence to surface in the rendered
522/// output (and in what order).
523///
524/// Predicates are typically `matches!`-driven (e.g.
525/// `|e| matches!(e, Evidence::HttpStatus(_))`) so the compiler enforces
526/// that the variant name still exists on `Evidence`. This replaced an
527/// earlier hand-rolled `u8` tag table that had to be kept in sync with
528/// the `Evidence` enum manually — a class of bug the type system can
529/// prevent.
530///
531/// Each evidence item is included at most once even if multiple
532/// predicates match; this matters when, e.g., a `webhook_signature` rule
533/// pins both `HttpStatus(_)` and `SignatureMismatch` and the input vector
534/// could contain ordering edge cases.
535fn pick(ev: &[Evidence], predicates: &[fn(&Evidence) -> bool]) -> Vec<Evidence> {
536    let mut out = Vec::new();
537    for predicate in predicates {
538        for e in ev {
539            if predicate(e) && !out.contains(e) {
540                out.push(e.clone());
541            }
542        }
543    }
544    out
545}
546
547#[cfg(test)]
548mod tests {
549    #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)]
550    use super::*;
551
552    #[test]
553    fn dns_failure_wins_over_other_signals() {
554        let ev = vec![
555            Evidence::DnsResolutionFailed {
556                host: "api.exmaple.com".into(),
557                message: "no such host".into(),
558            },
559            Evidence::HttpStatus(401),
560        ];
561        let d = diagnose("dns_config", &ev);
562        assert_eq!(d.rule, "dns_failure");
563        assert_eq!(d.severity, Severity::Critical);
564    }
565
566    #[test]
567    fn tls_failure_rule_matches() {
568        let ev = vec![Evidence::TlsHandshakeFailed {
569            peer: "api.example.com".into(),
570            reason: "certificate has expired".into(),
571        }];
572        let d = diagnose("tls_failure", &ev);
573        assert_eq!(d.rule, "tls_failure");
574        assert_eq!(d.severity, Severity::Critical);
575    }
576
577    #[test]
578    fn tls_failure_rule_orders_after_dns_failure() {
579        // If both DNS and TLS evidence are present, DNS wins (a connection
580        // that did not resolve cannot have completed TLS).
581        let ev = vec![
582            Evidence::DnsResolutionFailed {
583                host: "api.example.com".into(),
584                message: "no such host".into(),
585            },
586            Evidence::TlsHandshakeFailed {
587                peer: "api.example.com".into(),
588                reason: "certificate has expired".into(),
589            },
590        ];
591        let d = diagnose("ambiguous", &ev);
592        assert_eq!(d.rule, "dns_failure");
593    }
594
595    #[test]
596    fn connection_timeout_rule_matches() {
597        let ev = vec![Evidence::ConnectionTimeout {
598            elapsed_ms: 5012,
599            timeout_ms: 5000,
600        }];
601        let d = diagnose("timeout", &ev);
602        assert_eq!(d.rule, "connection_timeout");
603        assert_eq!(d.severity, Severity::High);
604    }
605
606    #[test]
607    fn webhook_signature_rule_matches() {
608        let ev = vec![
609            Evidence::HttpStatus(401),
610            Evidence::SignatureMismatch,
611            Evidence::ClockDriftSecs {
612                observed: 360,
613                tolerance_secs: 300,
614            },
615            Evidence::BodyMutatedBeforeVerification,
616        ];
617        let d = diagnose("webhook_signature", &ev);
618        assert_eq!(d.rule, "webhook_signature");
619        assert_eq!(d.severity, Severity::High);
620    }
621
622    #[test]
623    fn rate_limit_rule_requires_429_and_retry_after() {
624        let with_retry = vec![Evidence::HttpStatus(429), Evidence::RetryAfterSecs(12)];
625        assert_eq!(diagnose("rate_limit", &with_retry).rule, "rate_limit");
626
627        // 429 alone (no Retry-After) should not match the rule and falls to unknown.
628        let without_retry = vec![Evidence::HttpStatus(429)];
629        assert_eq!(diagnose("rate_limit", &without_retry).rule, "unknown");
630    }
631
632    #[test]
633    fn auth_missing_rule_matches() {
634        let ev = vec![
635            Evidence::HttpStatus(401),
636            Evidence::HeaderMissing {
637                name: "Authorization".into(),
638            },
639        ];
640        let d = diagnose("auth_missing", &ev);
641        assert_eq!(d.rule, "auth_missing");
642        assert_eq!(d.severity, Severity::Medium);
643    }
644
645    #[test]
646    fn bad_payload_rule_matches() {
647        let ev = vec![
648            Evidence::HttpStatus(400),
649            Evidence::JsonValidationError {
650                field: Some("amount".into()),
651                message: "Expected integer, got string.".into(),
652            },
653        ];
654        let d = diagnose("bad_payload", &ev);
655        assert_eq!(d.rule, "bad_payload");
656        assert!(d.likely_cause.contains("`amount`"));
657    }
658
659    #[test]
660    fn unknown_pattern_does_not_invent_a_cause() {
661        let ev = vec![Evidence::HttpStatus(418)];
662        let d = diagnose("teapot", &ev);
663        assert_eq!(d.rule, "unknown");
664        assert!(d.likely_cause.contains("does not match"));
665    }
666}