Skip to content

Route every severity a rule claims to a contact point that exists - #242

Merged
stxkxs merged 5 commits into
mainfrom
route-every-severity-label
Sep 5, 2026
Merged

Route every severity a rule claims to a contact point that exists#242
stxkxs merged 5 commits into
mainfrom
route-every-severity-label

Conversation

@stxkxs

@stxkxs stxkxs commented Sep 5, 2026

Copy link
Copy Markdown
Member

Issue #222. A rule labelled severity: page is a promise that a human is woken. A route to no contact point means the promise is kept by nobody, and the failure is silent in exactly the moment it matters — nobody is watching a page that never arrives to notice it did not.

Every label claiming a destination must resolve to one, derived from the routing tree rather than from a list of the rules someone checked.

Every rule under dashboards/base/alerting/ labels itself `severity: page` or
`severity: ticket`. Grafana routes a firing alert by matching those labels
against a notification policy tree, and the catalog declared no
GrafanaNotificationPolicy and no GrafanaContactPoint — so all 27 rules across
7 groups fell through to the workspace default policy and its empty default
contact point.

A rule labelled to page is asking for a human to be woken. Unrouted, the
promise is kept by nobody, and the failure is silent in exactly the moment it
matters: OperatorMetricsAbsent fires when the metrics path is dead, which is
the condition under which nobody is looking at a dashboard.

──────────────────────── The routing tree ────────────────────────

A GrafanaNotificationPolicy matching `severity` to two contact points, one
per urgency, carrying the same instanceSelector the rule groups use.

The root receiver is the low-urgency one rather than a dedicated fallback. An
alert whose severity matches no route is a rule the tree has not been taught
about, and the two answers to that are "deliver it somewhere a person reads"
and "drop it silently". The gate below makes that case impossible to arrive
at unnoticed; the root route makes it survivable if it does.

Grouped by service and component rather than by rule, so a fleet-wide failure
raising six rules against one service is one notification. The page repeats
hourly while firing; the ticket repeats every twelve, because a ticket that
re-notifies like a page teaches people to mute the channel both arrive in.

Credentials arrive the way the Grafana service-account token's do: an
ExternalSecret over the aws-secrets-manager ClusterSecretStore, with the
remote key patched per cluster by the dashboards ApplicationSet from the
cluster_name label — the secret is cluster-scoped so co-located siblings in
one account do not collide.

Neither receiver's secret is marked optional. An optional missing secret
produces a contact point with no integration key: Grafana accepts it, the
route resolves, and nothing is delivered — indistinguishable from a working
destination by every signal except an incident nobody answered. Required, the
contact point reports NotReady and says so.

──────────────────────── The gate ────────────────────────

scripts/check-alert-severity-routes.py, wired into `task validate` and CI.

The routing keys come from the POLICY, not from a list: whatever label keys
the tree matches on are the keys that make a claim about delivery. Then every
value a rule carries for one of those keys must match a route for that key;
every rule must carry every key the policy routes on, because a rule missing
one lands in the root bucket by a different path; and every receiver a route
names must be a contact point this catalog declares, in both directions — a
route to an undeclared receiver delivers nothing, and a contact point no
route names is a destination that rots the way an unread exemption does.

Regex matchers count as a route for their key and vouch for no particular
value. Deciding which values a pattern admits means running the pattern, and
reporting a value as routed because it looked like it might match would be
asserting the thing this gate exists to check.

It states its own scope. Two facts must also hold before a page arrives and
neither is readable from this tree: whether Amazon Managed Grafana ACCEPTS
the contact point, which its workspace configuration decides, and whether the
credential is seeded, which Secrets Manager holds. A green run says the
catalog routes every urgency it claims. It does not say a page arrived.

──────────────────────── What proves it ────────────────────────

A positive control in scripts/tests/controls.py relabels one rule with a
severity the tree does not route — the rule still parses, still evaluates,
still changes state in the alert list, and reaches the root receiver instead
of the destination it asked for.

Eleven mutants, each killed and each naming a test: the keys hardcoded to
`severity` instead of read from the policy; only top-level routes treated as
delivery decisions; a regex matcher vouching for its pattern as a value; a
missing label, an unrouted value, an undeclared receiver and an unreferenced
contact point each not a finding; no policy, two policies, and a policy
matching on nothing each accepted; and an empty rule corpus reading as a
routed catalog.

Two probes in scripts/tests/reverify-gates.sh, floor 35 → 38, one per
direction: a severity the tree does not route, and a route to a receiver
nobody declares.

──────────── Two things the repository's own gates caught ────────────

The ExternalSecret's first filename tripped the `*credentials*` ignore rule
that keeps secrets out of git — renamed rather than force-added.
check-externalsecret-keys.py then rejected it for holding an unpatched remote
key, which is a real per-cluster requirement: the committed name is a
placeholder, and unpatched it resolves to a secret that exists in no account.

The gate's own refusal for a missing directory composed its message with
`relative_to`, which RAISES for a path outside the repository. A refusal that
crashes while writing its own message exits with a traceback and the status
of a rejection, which is the reading these exit codes exist to keep apart.
`under_root` returns the path as given when it is outside, and the test that
found it asserts the refusal rather than the message.

──────────────────────── Verification ────────────────────────

ruff clean; mypy clean over 48 files; 493 tests across 19 modules; coverage
ratchet 42.6% (floor 40 → 42), 14 per-gate floors held, this gate at 97%
against a floor of 95; controls.py 18 controls; empty-corpus.py 30 probed
gates; reverify-gates.sh 41/41; task validate exit 0; check-named-things.py
195 references resolve; yamllint clean; check-workflows.sh clean at MEDIUM
and above.

Co-authored-by: stxkxsbot <275011021+stxkxsbot@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

CI Results

Check Status
Zero-placeholder gate ✅ success
YAML Lint ✅ success
Dashboard gate (live grafana.com ids + AMG-saveable) ✅ success
Kyverno policy tests (+ verify-images contract) ✅ success
Fork-safety gate (no hardcoded org, blocking) ✅ success
Helm-render (every addon × every env) ✅ success
Rendered images carry no unacknowledged CRITICAL ✅ success
Policy-admission (Enforce-tier Kyverno vs the fleet) ✅ success
ApplicationSet schema + sync waves ✅ success
Appset render (Karpenter subnet selector) ✅ success
Secret scan (gitleaks) ✅ success
Render + assert + schema + misconfig (all environments) ✅ success
Renovate manager coverage ✅ success
Loki volume warns before cutoff ✅ success
Falco loads every rule set it installs ✅ success
Gate scripts lint and typecheck ✅ success
Policy validity (kustomize + kyverno) ✅ success
The gates themselves are tested ✅ success
Pods name ServiceAccounts that exist ✅ success
Catalog CRs admissible against the operator chart ✅ success
Catalog source reads its revision ✅ success

All checks passed.

`mutateDigest: true` is the whole reason the enforcing tier enforces.
Verification resolves a tag to a digest at admission; the kubelet resolves the
same tag again at pull time. Pinning at admission is what stops anyone who can
move a tag between those two moments from running an unverified image under a
policy reporting success.

In this repository that guarantee was a field value other files read, never a
behaviour anything ran. Every Kyverno suite loads a BASE policy, where the
field is false because Kyverno rejects true under an Audit failure action — so
the only rendition carrying it was under no test at all.
check-image-verification.py declines to assert the field and delegates the
pairing; check-policy-validity.py asserts Enforce implies true and then applies
a probe pod whose image is outside `ghcr.io/nanohype/*`, so the rule never
matches and the mutation path never runs.

──────────────────────── What now runs ────────────────────────

scripts/check-digest-rewrite.sh renders each ENFORCING overlay and admits a
pod through it — a real signed `ghcr.io/nanohype/*` release, referenced by TAG,
because a digest-pinned fixture needs no rewrite and would satisfy every check
against a policy that had stopped doing it. That fixture property is asserted
rather than assumed.

The overlays are enumerated from the tree, not named in the script. An overlay
added later is one more tier depending on a step, and it has to be executed
too.

It reaches the registry for the manifest and Rekor for the transparency-log
entry, which is the round trip admission makes — this exercises the path rather
than a model of it. A verification that could not REACH those is told apart
from a rewrite that did not happen: the first exits 2, because it observed
nothing.

──────────── How the result is observed, and why by report ────────────

The Kyverno CLI offers no machine-readable channel carrying the object a
verifyImages mutation admitted. Three were tried and each is recorded in the
script. `kyverno test`'s `patchedResources` is compared against EVERY engine
response for the rule, and a verifyImages rule produces two — one holding the
resource as submitted, one holding it mutated — so one comparison always diffs
whichever file is supplied, and supplying both produces a cross product with
two failures. `kyverno apply -o` writes a file for mutate rules and leaves it
empty for this one. The JSON policy report carries the verdict and the message
and not the object.

So the reading is the CLI's own detailed report. The expected patch supplied to
it is the pod AS SUBMITTED, and that direction is what makes the reading
non-circular: the mutated response differs from it, the report prints what the
engine produced, and a digest in that output can only have come from the
engine. An expectation carrying the digest would have the report echo the
fixture back.

That is text, and text is the weaker reading — but it fails in the safe
direction. The assertion is that one reference token appears, so a report that
stops printing it, wraps it, or renames the column makes the token absent and
this gate red. It cannot go quiet.

──────────────────────── What it does not establish ────────────────────────

That a cluster admits what the CLI admits. The engine is the same code and the
policy is the rendered one, and no environment reachable from this repository
runs an API server. docs/threat-model.md now says that in those words, in place
of a paragraph that had been mangled by an earlier edit into a duplicated
fragment and an orphaned sentence.

ci.yml carried a claim this work disproves — that `kyverno test` cannot reach
Fulcio/Rekor and never verifies a signature. It does; the reason the suites
never verified one is that they load base policies whose rule the fixtures do
not match. Corrected rather than left standing beside a step that contradicts
it.

──────────────────────── What proves it ────────────────────────

Both directions on the real tree. Unmutated: two enforcing overlays each admit
the tagged pod carrying the digest verification resolved, exit 0. With the
production overlay's patch flipped to `mutateDigest: false` — a change every
YAML-reading gate still passes, because the field is simply gone rather than
wrong — this exits 1 naming the overlay and what the tag then means. That is a
probe in scripts/tests/reverify-gates.sh, floor 38 → 40, and the harness runs
43 checks.

Registered in controls.py's NEEDS_NETWORK_SH rather than carrying a positive
control: the round trip IS what it executes, and a control for it offline would
be a control for a verification that did not happen.

──────────────────────── Verification ────────────────────────

ruff clean; mypy clean over 48 files; 493 tests across 19 modules; controls.py
18 controls; empty-corpus.py 31 probed gates; reverify-gates.sh 43/43; task
validate exit 0; check-named-things.py 196 references resolve; yamllint clean;
check-workflows.sh clean at MEDIUM and above.

Co-authored-by: stxkxsbot <275011021+stxkxsbot@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

CI Results

Check Status
Zero-placeholder gate ✅ success
YAML Lint ✅ success
Dashboard gate (live grafana.com ids + AMG-saveable) ✅ success
Kyverno policy tests (+ verify-images contract) ✅ success
Fork-safety gate (no hardcoded org, blocking) ✅ success
Helm-render (every addon × every env) ✅ success
Rendered images carry no unacknowledged CRITICAL ✅ success
Policy-admission (Enforce-tier Kyverno vs the fleet) ✅ success
ApplicationSet schema + sync waves ✅ success
Appset render (Karpenter subnet selector) ✅ success
Secret scan (gitleaks) ✅ success
Render + assert + schema + misconfig (all environments) ✅ success
Renovate manager coverage ✅ success
Loki volume warns before cutoff ✅ success
Falco loads every rule set it installs ✅ success
Gate scripts lint and typecheck ✅ success
Policy validity (kustomize + kyverno) ✅ success
The gates themselves are tested ✅ success
Pods name ServiceAccounts that exist ✅ success
Catalog CRs admissible against the operator chart ✅ success
Catalog source reads its revision ✅ success

All checks passed.

It landed here by mistake and belongs to its own issue and its own branch,
where it is now — execute-the-digest-rewrite, cherry-picked onto main with the
reverify floor set for that branch's probe count.

Reverted rather than rewritten, because this branch is pushed and carries an
open pull request: a force-push would move commits a reviewer may already have
read. The pair nets to no change at squash, and the tree here is identical to
the one before the mistake.

Co-authored-by: stxkxsbot <275011021+stxkxsbot@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

CI Results

Check Status
Zero-placeholder gate ✅ success
YAML Lint ✅ success
Dashboard gate (live grafana.com ids + AMG-saveable) ✅ success
Kyverno policy tests (+ verify-images contract) ✅ success
Fork-safety gate (no hardcoded org, blocking) ✅ success
Helm-render (every addon × every env) ✅ success
Rendered images carry no unacknowledged CRITICAL ✅ success
Policy-admission (Enforce-tier Kyverno vs the fleet) ✅ success
ApplicationSet schema + sync waves ✅ success
Appset render (Karpenter subnet selector) ✅ success
Secret scan (gitleaks) ✅ success
Render + assert + schema + misconfig (all environments) ✅ success
Renovate manager coverage ✅ success
Loki volume warns before cutoff ✅ success
Falco loads every rule set it installs ✅ success
Gate scripts lint and typecheck ✅ success
Policy validity (kustomize + kyverno) ✅ success
The gates themselves are tested ✅ success
Pods name ServiceAccounts that exist ✅ success
Catalog CRs admissible against the operator chart ✅ success
Catalog source reads its revision ✅ success

All checks passed.

Brings in the digest-rewrite gate. One collision, in the reverify harness's
floor on how many checks it ran.

─── scripts/tests/reverify-gates.sh ───

Both sides raised MIN_CHECKS off the same base of 35, each by the number of
`run` lines it added: the routing work to 38 for three, the digest work to 37
for two. Neither number is right for a tree holding both.

Resolved to 40. The floor tracks the harness's own size — it sits three under
the `run` count on both parents and on the base, which is the margin that lets
a check be retired without the harness turning red on the next run while still
catching a harness that stopped executing most of itself. The merged file
carries 43 `run` lines.

Taking either side verbatim would have been a floor a truncated harness clears:
at 37, three of the checks this tree adds could stop running and the floor
would still pass.
The routing gate read `dashboards/base/alerting/*.yaml`. A cluster receives what
`dashboards/base/kustomization.yaml` lists in `resources`, and that is an
explicit list a line can leave.

─── The defect ───

Delete `- alerting/notification-policy.yaml` from the kustomization and the
render carries seven GrafanaAlertRuleGroups, 27 rules labelled `severity: page`,
and no routing tree. Every alert falls to the workspace default policy and its
empty default contact point. The gate written to make exactly that impossible
reported the tree as fully routed, because the file it read was still on disk
and still described complete delivery.

Nothing else in the catalog sees it. `kustomize build` succeeds — a resource
list is shorter, not malformed. kubeconform SKIPs the grafana.integreatly.org
CRDs. No other gate reads a notification policy. The symptom is an alert that
changes state in the Grafana list and reaches nobody, which is indistinguishable
from an alert that has not fired.

The gate closed the case of a routing tree that is wrong. This is the case of a
routing tree that is not there, arriving by a different door.

─── scripts/check-alert-severity-routes.py ───

`shipped(root)` renders the kustomization and returns (kind, metadata.name) for
every alerting object the render carries. The root is `ALERTING.parent` rather
than a second literal, so the two readings cannot come to describe different
trees.

`main()` compares that against the same set read off disk, and reports both
directions:

  * on disk and not rendered — routing this gate checked and no cluster
    receives;
  * rendered and not on disk — an alerting object the gate never opened. That
    direction decides which tree Grafana obeys: a second GrafanaNotificationPolicy
    entering the render from elsewhere under dashboards/base reconciles too, and
    whichever lands last wins.

Both readings are kept rather than one replacing the other. The render decides
what a cluster gets, but a rendered document no longer carries the file it came
from, and "a rule is unrouted" across eight near-identical files is not
actionable without one.

A render that fails and a `kustomize` that is not installed are both exit 2. A
kustomization that does not build says nothing about what a cluster receives,
and the files on disk say nothing about it either — that is a gate that could
not run, not a tree it rejected.

─── scripts/tests/test_alert_severity_routes.py ───

`TheVerdict`'s planted fixtures now write a real kustomization and are rendered
rather than compared against a stub, so the cases that predate this change
exercise the render path as well. `policy()` takes a name because kustomize
rejects two resources sharing an id, and the two-policy case has to plant two
distinct ones to reach the gate.

`WhatShipsDecides` covers the new verdicts: each of the three object kinds left
out of `resources`, an alerting object rendered from outside the directory, a
render that fails, and that the second reading shells out rather than globbing
the same directory twice. That last one is what a gate faking this would pass.

`TheShippedRootIsTheOneDelivered` asserts over the tree that an ApplicationSet
delivers the root the gate renders — in the tests rather than in the gate,
because a root nobody delivers is a fleet-delivery question, not a routing one.

─── scripts/tests/reverify-gates.sh ───

A fourth planted defect for this gate: the notification policy dropped from the
kustomization's resources. Every earlier check still passes on that tree — the
rules are labelled, the routes match, the contact points are declared — which is
what makes it worth planting. MIN_CHECKS follows to 41 against 44 `run` lines.
@stxkxs

stxkxs commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Merged origin/main (7af9bb9) in. One collision, in scripts/tests/reverify-gates.sh: both sides raised MIN_CHECKS off the same base of 35 — this branch to 38 for the three run lines it adds, the digest work to 37 for its two. The floor sits three under the run count on both parents and on the base, so the merged tree's 43 lines put it at 40. Taking either side verbatim resolves a floor downward: at 37, three of the checks this branch adds could stop running and still pass.

Then the question of whether the two gates agree on what their corpus is. They did not, and it was a live false green on this branch.

check-digest-rewrite.sh derives the enforcing tier from what ships — the kustomizations carrying the mutateDigest patch, rendered. check-alert-severity-routes.py derived its corpus from what exists: dashboards/base/alerting/*.yaml. dashboards/base/kustomization.yaml names its resources explicitly, so the two come apart with a deletion:

$ sed -i '/alerting\/notification-policy.yaml/d' dashboards/base/kustomization.yaml
$ kustomize build dashboards/base | grep -c 'kind: GrafanaNotificationPolicy'
0
$ kustomize build dashboards/base | grep -c 'kind: GrafanaAlertRuleGroup'
7
$ ./scripts/check-alert-severity-routes.py
✓ every alert rule routes to a declared contact point: 27 rule(s) across 7 group(s) …

27 rules labelled severity: page, no routing tree, and the gate written to make that impossible calls it fully routed. kustomize build succeeds, kubeconform SKIPs the grafana.integreatly.org CRDs, and no other gate reads a notification policy — so nothing in the tree sees it.

This branch closed the case of a routing tree that is wrong. What stayed open was the tree not being there, by a different door: present in the directory the gate reads, absent from the list the cluster is built from.

d4895c8 renders the kustomization as a second reading and reports both directions — on disk but not rendered, and rendered but not on disk. The second matters as much: another GrafanaNotificationPolicy entering the render from elsewhere under dashboards/base reconciles too, and whichever lands last decides which tree Grafana obeys. Both readings are kept because the render decides but carries no filename, and "a rule is unrouted" across eight near-identical files is not actionable without one. A failed render and a missing kustomize are both exit 2.

Proof — four defects planted in the kustomization, each caught and each naming the file; then the tests by mutation with __pycache__ cleared between each:

mutant killed by
disk-not-rendered finding removed 4 tests, incl. test_the_finding_names_the_file
rendered-not-on-disk finding removed test_an_alerting_object_rendered_from_outside_the_directory_is_reported
shipped() globs the directory instead of rendering 7 tests, incl. test_the_render_is_read_rather_than_the_directory_listed_again

The third is the one worth keeping: a gate globbing the same directory twice passes every routing test written before this round.

Local run at d4895c8: ruff clean, mypy clean over 48 files, 501 tests across 19 modules, coverage 43.0% against the 42% ratchet with 10 of 25 gates uncovered against a ceiling of 11, controls.py 18 controls, empty-corpus.py 31 probed gates, reverify-gates.sh 44/44 against the floor of 41, task validate clean.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

CI Results

Check Status
Zero-placeholder gate ✅ success
YAML Lint ✅ success
Dashboard gate (live grafana.com ids + AMG-saveable) ✅ success
Kyverno policy tests (+ verify-images contract) ✅ success
Fork-safety gate (no hardcoded org, blocking) ✅ success
Helm-render (every addon × every env) ✅ success
Rendered images carry no unacknowledged CRITICAL ✅ success
Policy-admission (Enforce-tier Kyverno vs the fleet) ✅ success
ApplicationSet schema + sync waves ✅ success
Appset render (Karpenter subnet selector) ✅ success
Secret scan (gitleaks) ✅ success
Render + assert + schema + misconfig (all environments) ✅ success
Renovate manager coverage ✅ success
Loki volume warns before cutoff ✅ success
Falco loads every rule set it installs ✅ success
Gate scripts lint and typecheck ✅ success
Policy validity (kustomize + kyverno) ✅ success
The gates themselves are tested ✅ success
Pods name ServiceAccounts that exist ✅ success
Catalog CRs admissible against the operator chart ✅ success
Catalog source reads its revision ✅ success

All checks passed.

@stxkxs
stxkxs marked this pull request as ready for review September 5, 2026 04:59
@stxkxs
stxkxs merged commit 5f293ee into main Sep 5, 2026
31 checks passed
@stxkxs
stxkxs deleted the route-every-severity-label branch September 5, 2026 05:00
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