Skip to content

Add an optional Critical tier for between/outside range alert predicates (#3372) - #3380

Merged
erikdarlingdata merged 1 commit into
devfrom
feature/3372-range-critical-tier
Sep 12, 2026
Merged

Add an optional Critical tier for between/outside range alert predicates (#3372)#3380
erikdarlingdata merged 1 commit into
devfrom
feature/3372-range-critical-tier

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What

Adds an optional Critical tier for the between/outside range custom-alert predicates (#3372, part of #3285). Range ops (#3351/#3378) were Warning-only; they can now carry a second, more-severe band via two new nullable predicate fields criticalLowerBound / criticalUpperBound. Absent → Warning-only (today's behavior, unchanged). Scalar ops (gt/ge/lt/le) are untouched.

Predicate JSON shape (two-tier range rule)

// outside: crit band is WIDER and CONTAINS the warn band
{ "op": "outside", "lowerBound": 10, "upperBound": 100,
  "criticalLowerBound": 5, "criticalUpperBound": 200 }

// between: crit band is NARROWER and is CONTAINED BY the warn band
{ "op": "between", "lowerBound": 10, "upperBound": 100,
  "criticalLowerBound": 40, "criticalUpperBound": 60 }

The existing lowerBound/upperBound are the Warning band; the crit pair is the Critical band.

How severity routes through RangeBreaches

"More severe" means further from healthy, so the nesting flips by op. The shared private RangeBreaches(op, lower?, upper?, value) stays the single band authority and is evaluated at both bands — the crit band is just another (op, lower, upper) triple:

  • IsBreaching(value) tests the warn band → "breaches at all".
  • SeverityFor(value) for a range op → Critical iff the crit band exists AND RangeBreaches(Op, criticalLowerBound, criticalUpperBound, value), else Warning.

Because the validator enforces the nesting, the crit firing region is always a subset of the warn firing region, so the warn band is the outer envelope and SeverityFor only asks the incremental "also in crit?" question. No parallel band math. The #3341 open-incident severity-change path already calls SeverityFor, so a range rule now escalates Warning→Critical (and de-escalates) automatically.

Op-specific nesting validation

  • outside: criticalLowerBound <= lowerBound < upperBound <= criticalUpperBound (WIDER). A too-narrow crit band is rejected.
  • between: lowerBound <= criticalLowerBound <= criticalUpperBound <= upperBound (NARROWER, and its own bounds ordered). A too-wide or inverted crit band is rejected.
  • Crit bounds are all-or-nothing (both or neither) and numeric; a scalar op still rejects any range/crit bounds and a range op still rejects warnThreshold/criticalThreshold.

#3378 COUNT-on-zero guard interaction

The guard tests the warn band (RangeBreaches(op, lower, upper, 0)), which is a complete 0-guard for both bands: the crit band was just validated to nest inside the warn firing region, so if the warn band doesn't fire on 0 the crit band can't either — and any crit band that would fire on 0 could only do so alongside a warn band that also fires on 0, which is already rejected. No separate crit-band 0-check is needed. The guard still fires with a crit band present (regression covered).

Render + editor

  • Delivered alert (FiredThreshold) + rule-card chip convey both bands: outside 10 - 100 (crit outside 5 - 200) / between 10 - 100 (crit 40 - 60) (card chip uses the existing en-dash style). Numeric threshold twin stays null for range, consistent with Add between/outside range predicate operators for custom alert rules (#3351) #3371.
  • Editor (wwwroot/js/alert-editor.js): two optional Critical lower bound / Critical upper bound inputs appear for a range op, with help text stating the nesting; mapped to/from the new fields in definitionToModel/modelToDefinition, with an all-or-nothing + op-nesting client-side save-blocker. Backend /api/alerts/validate stays the authority.
  • MCP validate_custom_alert_rule description updated to document the optional crit band.

Tests

Darling.Tests build clean; CustomAlertRuleDefinitionTests (79) and the pure custom-alert classes (evaluate-now, severity-escalation, display, MCP tools, templates, teardown, health, tag-scope, cap — 70) all pass. New coverage: two-tier severity for both ops (low + high side for outside; warn-not-crit vs crit for between), op-specific nesting rejection (too-narrow outside / too-wide + inverted between), all-or-nothing + non-numeric crit bounds, both-band FiredThreshold render, and the COUNT-on-zero guard still firing with a crit band present. Warning-only range regression guards (#3371/#3378) unchanged.

Service builds 0 Warning(s) / 0 Error(s).

Deferred

Nothing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y5xoN5PzhzYfHHrfutyeEe

…tes (#3372)

Range custom-alert ops (#3351/#3378) were Warning-only. This adds an OPTIONAL
second, more-severe band via two nullable predicate fields criticalLowerBound /
criticalUpperBound; absent, a range rule is Warning-only exactly as before.

"More severe" means further from healthy, which flips the nesting per op:
- outside breaches further OUT, so its crit band is WIDER and CONTAINS the warn
  band (criticalLowerBound <= lowerBound < upperBound <= criticalUpperBound).
- between breaches DEEPER in, so its crit band is NARROWER and is CONTAINED BY the
  warn band (lowerBound <= criticalLowerBound <= criticalUpperBound <= upperBound).

Severity routes through the single RangeBreaches band authority evaluated at BOTH
bands: a breaching range op is Critical iff the crit band exists and the value also
breaches it (the crit triple), else Warning. Because the crit firing region is a
subset of the warn firing region under the enforced nesting, IsBreaching (the warn
band) stays the "breaches at all" envelope and the #3378 COUNT-on-zero guard on the
warn band is a complete 0-guard for both bands (no parallel crit-band math).

Validation: crit bounds are all-or-nothing, numeric, and op-specifically nested; a
scalar op still rejects any range/crit bounds and a range op still rejects scalar
thresholds. Delivered-alert render and the rule-card chip convey both bands
(outside 10 - 100 (crit outside 5 - 200) / between 10 - 100 (crit 40 - 60)); the
numeric threshold twin stays null for range. The editor offers two optional
Critical bound inputs when a range op is selected, with nesting help text, mapped
to/from the new fields; the backend /api/alerts/validate stays the authority.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5xoN5PzhzYfHHrfutyeEe
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Reviewed. This is a clean, well-scoped addition — no issues found.

  • Correctness: The op-specific nesting (outside wider/containing, between narrower/contained) is validated on both the backend (CustomAlertRuleDefinition.TryParse) and mirrored client-side in alert-editor.js's save-blocker, with matching error messages. Verified the "crit firing region is a subset of the warn firing region" claim by hand for both ops — holds in both directions, so SeverityFor and the IsBreaching/0-guard reuse are sound. The all-or-nothing crit-bound handling is consistent front and back.
  • Lite/Darling parity: Custom alert rules (CustomAlertRuleDefinition) are Darling-only — Lite has no equivalent construct, so no counterpart change is needed here.
  • Security: Rendered chip/threshold text goes through textContent, not innerHTML; predicate values are numeric. No injection surface.
  • Tests: New coverage (severity-by-band for both ops, nesting rejection, all-or-nothing/non-numeric crit bounds, FiredThreshold render, COUNT-on-zero guard with a crit band present) matches the described behavior.

No blocking findings.

@erikdarlingdata
erikdarlingdata merged commit b306f89 into dev Sep 12, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/3372-range-critical-tier branch September 12, 2026 20:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant