Claude Skill

kubectl-investigator

Investigate a live or recent incident in a Kubernetes cluster. Anchor the window, bisect the change surface (rollouts, ConfigMaps/Secrets, RBAC, HPA/cluster changes, CronJobs), classify against four reference failure paths (OOM, DNS, cascading-failure, deploy-correlator), confirm

LLM Mart · 0 points · 5 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download anyshift-io-sre-skills-skills_kubectl-investigator-a7af922.zip · 350 KB
Part of anyshift-io/sre-skills — 5 skills

Install

skills CLI npx skills add https://github.com/anyshift-io/sre-skills/tree/main/skills/kubectl-investigator
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install anyshift-io-sre-skills@llmmart
Git git clone https://github.com/anyshift-io/sre-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole anyshift-io/sre-skills collection as a plugin from our marketplace. Git is the plain clone.

README

kubectl-investigator

Methodology-shaped SRE skill for investigating a live or recent incident on Kubernetes.

Anchors the incident window, bisects the change surface (rollouts, ConfigMaps/Secrets, RBAC, HPA/cluster changes, CronJobs), classifies the failure against four reference paths (OOM, DNS, cascading-failure, deploy-correlator), confirms with three independent signals, quantifies blast radius, and proposes mitigation before root cause.

Files in this skill

File What it is
SKILL.md The methodology. This is what an AI agent loads.
examples/ Eleven worked examples covering the four reference paths, the FAILURE_MODES escalation rules, and edge cases.
fixtures/ Committed telemetry / event snapshots (pod events, metrics, logs, traces, rollout/RBAC/cluster changes) that drive the replay tests. No live cluster or credentials required.
tests/ Replay tests that exercise the methodology against the fixtures.
FAILURE_MODES.md Where this skill is wrong and where the agent should escalate.

Quality bar (this skill passes all three)

  • Two worked examples required by the bar; this skill ships eleven covering the four reference paths, the FAILURE_MODES escalation rules, and edge cases.
  • Fixture-based replay tests, runnable with no live cluster or credentials. 99 assertions across the 11 tests (for t in tests/replay_*.py; do python "$t" || exit 1; done).
  • Explicit failure-modes section (FAILURE_MODES.md).

Measured lift

An LLM ablation eval is committed under tests/eval/. An automated run with Claude Sonnet 4.6 as both agent and LLM-judge (N=3 trials per cell, 66 trials, scored against the 7-item rubric in rubric.md) measured a +0.82 / 7 (+15%) lift of an agent loaded with this SKILL.md (mean 6.36) over an agent given the same telemetry with no methodology (mean 5.55). Treatment wins on 9 of 11 fixtures, ties on 2, and loses on none; the largest lifts are on the escalation cases the cold agent doesn't know to guard against (third-party rate-limit +2.33, confirmation-bias +1.67, capacity-bound +1.33). See tests/eval/README.md for the full per-fixture table and the honest caveats on the methodology. Reproduce with python tests/eval/run_eval.py --trials 3.

How to use

As a Claude Code / Claude Skills user

Drop skills/kubectl-investigator/ into your skills directory and invoke when a Kubernetes incident is in progress. The agent reads SKILL.md and follows the methodology end-to-end against your cluster telemetry (kubectl, the events API, kube-state-metrics, Prometheus).

As a contributor adding a new reference path or example

  1. Add a new example file under examples/ mirroring the existing ones.
  2. Commit fixtures under fixtures/<example-slug>/ (pod events, metrics, traces, logs, rollout/RBAC/cluster changes as relevant).
  3. Add a replay test under tests/replay_NN_<example-slug>.py that asserts the methodology produces the correct classification + mitigation.
  4. Update SKILL.md if the new path is reference-quality (i.e. covers >5% of real Kubernetes incidents); otherwise keep it in examples/ only.

See the top-level CONTRIBUTING.md for the repo-wide bar.

Anyshift integration (opt-in)

The methodology runs vendor-neutral by default (any cluster, kubectl + your telemetry). Opting in to the Anyshift MCP for step 2 (change-surface bisection) gives the agent a versioned resource graph that links rollouts, RBAC changes, and cluster/infrastructure changes to the Kubernetes resources implicated in the incident.

A measured "with vs without" delta will be published in this section once the MCP integration has been exercised against the replay tests above. Numbers will replace this note directly.

License

Apache 2.0.

Skill manifest

kubectl-investigator

Methodology skill for investigating a live or recent incident on Kubernetes. Produces a timeline, a ranked set of hypotheses, a blast-radius estimate, and a recommended mitigation. Hands off cleanly to postmortem-author once the incident is mitigated.

Scope: workloads running on Kubernetes (Deployments, StatefulSets, DaemonSets, Jobs/CronJobs) and the cluster primitives around them (Services, Ingress, CoreDNS, ConfigMaps/Secrets, RBAC, HPA, nodes). External dependencies (third-party APIs, partner TLS endpoints, managed databases) are in scope only as seen from a Kubernetes workload — the methodology investigates the cluster-side symptom and the in-cluster change surface.

When to invoke

  • A PrometheusRule / Alertmanager alert just fired on a workload and the agent needs to triage before paging a human.
  • A user asks "what is breaking in the cluster right now" or "why did Deployment X just page".
  • A kubectl rollout / Helm release / Argo CD sync went out in the last hour and a metric moved; need to know whether they are linked.
  • Pods are crash-looping, OOMKilled, or Pending, or customer impact is reported with no alert yet; need to find the failing surface.

The methodology, in order

The order matters. Skipping a step produces confident wrong answers.

1. Anchor the window

Lock two timestamps before doing anything else:

  • T0: the trigger timestamp. Apply this order:
    1. If an alert is provided as the trigger, T0 = alert fire time. Use this verbatim. Do not substitute an earlier "first error in logs / first OOMKilled event" timestamp just because one exists; the alert fire time is the agreed-upon coordination point for the incident.
    2. If a customer report is the trigger, T0 = report timestamp.
    3. If neither exists (operator-initiated investigation, "pods slow all morning"), T0 = earliest unambiguous signal in the available telemetry (first OOMKilled event, first SERVFAIL, first error-rate inflection), and mark T0 as ambiguous (see below).
  • Tnow: current time, or the timestamp the investigation was triggered.

Every later signal is filtered to [T0 - 15min, Tnow]. The 15-minute lead-in catches changes that landed just before the symptom surfaced (a rollout's pods take time to roll, an HPA scale-down takes time to bite).

If T0 is ambiguous (operator-triggered with no alert, or "slow all morning"-class reports), the methodology's recommended mitigation in step 6 must begin with "re-run the investigation with a widened window" before any irreversible action. The change identified within the original narrow window is likely incomplete; the actual causal change may sit outside it. Do not silently round and do not skip the re-run step.

2. Bisect the change surface

Pull every change event that overlaps the window. On Kubernetes the change surface is:

  • Workload rollouts: kubectl rollout / kubectl apply / kubectl set image, new image tags, new ReplicaSets, Helm releases, Argo CD / Flux syncs.
  • Cluster / capacity changes: node-pool scaling, node cordon/drain, resource requests/limits edits, HPA / VPA changes, PodDisruptionBudget edits, PV/PVC/StorageClass changes.
  • RBAC / ServiceAccount changes: Role / ClusterRole / RoleBinding / ClusterRoleBinding edits, ServiceAccount or its token/permissions changed (these break Secret reads, API access, admission).
  • Config / feature-flag changes: ConfigMap / Secret edits, CoreDNS Corefile ConfigMap edits, Ingress/NetworkPolicy changes, feature-flag flips.
  • Admission / operator changes: Validating/MutatingWebhookConfiguration edits, CRD or controller upgrades.
  • CronJobs / Jobs that ran in the window (batch, data migrations, cluster maintenance jobs).

If the window has zero change events, treat it as a strong signal in itself: the failure is likely external (upstream provider, certificate expiry, DNS, capacity drift from organic growth) rather than a self-inflicted regression.

3. Classify against the four reference paths

Match the failure shape to one of these four canonical paths first. They cover the majority of Kubernetes incidents; only branch out once they are ruled out.

Path Tell-tale signals Confirming evidence
OOM Container restart count climbing, CrashLoopBackOff, RSS / working-set at the container memory limit, OOMKilled pod events, retry storm from upstream Container exit code 137, reason: OOMKilled in pod events / kubectl describe pod, working-set metric at or above resources.limits.memory at T0, a recent rollout that increased per-pod memory footprint
DNS Connection failures with NXDOMAIN / SERVFAIL in logs, getaddrinfo / no such host errors, sudden latency on in-cluster Service calls, *.svc.cluster.local resolution failing while external hosts resolve CoreDNS error / SERVFAIL counts elevated, a recent change to the CoreDNS Corefile ConfigMap, kube-dns/CoreDNS pod restarts, ndots/search-domain or NetworkPolicy change in the window
Cascading-failure One in-cluster dependency degrades, retry counts spike across callers, connection pools / thread pools / sidecar (Envoy) circuits saturate, queue depth grows Latency increases hop-by-hop toward the root Service, retry-budget metrics, circuit-breaker state changes, 2nd-order Deployments start failing, upstream Pod Unhealthy / readiness-probe failures
Deploy-correlator Metric breaks within 5 minutes of a rollout on the failing surface, only pods from the new ReplicaSet show the symptom Canary / blue-green or rolling-update split shows old-RS healthy / new-RS failing, kubectl rollout undo restores the metric, the rollout diff touches the failing code path

If the failure does not match any of the four, classify as "outside reference paths" and document why. Outside-reference-paths means the methodology has no reference path for the failure shape, so its confidence in the root cause is low and escalation to a human is mandatory (step 6). It does not mean the agent does nothing: a pre-approved safe mitigation (traffic-shift to a healthy peer, feature-flag-off) is still recommended as the top action when available, with the root-cause investigation escalated in parallel. See step 6 for the exact ordering.

4. Confirm with three independent signals

Never declare a hypothesis on one signal. Require at least three of the following, drawn from independent sources:

  • Pod / cluster events (kubectl get events, kubelet: OOMKilled, BackOff, Unhealthy, FailedScheduling).
  • Logs (application container logs, system component logs).
  • Metrics (request rate, error rate, latency, saturation, working-set, CoreDNS error rate — from Prometheus / kube-state-metrics).
  • Traces (distributed traces showing the failing hop / Service).
  • Change events (rollouts, ConfigMap/Secret, RBAC, HPA/cluster changes).
  • External signals (customer reports, status pages of dependencies the workload calls).

Two signals from the same source (e.g. two log lines) count as one. The independence requirement is the guard against confirmation bias.

Split aggregate signals before trusting them. Error rate, latency, and saturation are usually reported as a single number across every region, cluster, AZ, shard, or canary/stable split. Before classifying, break each aggregate down along these dimensions. A per-dimension asymmetry — one region failing while its peer is healthy, one shard hot while the rest are flat — is a first-class diagnostic signal, and aggregate metrics actively hide it (a 25% failure in one of two equally-sized regions shows up as a moderate ~12% aggregate that matches no clean reference path).

When the failing and healthy slices run the same image tag / same code, a code-regression path (OOM, deploy-correlator) is ruled out by construction: identical code cannot fail in one slice and not the other. The cause is environmental — config/GitOps drift, a stale Service reference, per-region capacity, an external dependency reachable from only one slice. A confirmed asymmetry short-circuits the four-path search: stop trying to fit OOM/DNS/cascade/deploy-correlator on the aggregate, classify "outside reference paths" with a regional-asymmetry (or shard/AZ-asymmetry) reason, and move to step 5. Continuing to hunt for a reference-path match on aggregate signals after an asymmetry is detected wastes the investigation and is the single most common way this step runs long.

5. Quantify blast radius

Before recommending action, estimate:

  • Users affected (count or percentage of traffic).
  • Surfaces affected (which Services/endpoints, which namespaces, which clusters/regions, which customer segments).
  • Business impact (revenue, SLO burn, contractual obligations if known).

A wrong mitigation that touches more surface than the incident itself is worse than the incident.

6. Propose mitigation before root cause

Mitigation comes first. Root cause comes after the bleeding stops.

Hard constraints, in order. Check these before ranking the standard actions below:

  • If the classification from step 3 is "outside reference paths", escalation to a human is mandatory — but it is not automatically the top action. Two mitigations are pre-approved as safe because they are reversible and contained, and when one of them is available it becomes the top recommended action:

    • Traffic-shift away from the failing slice to a healthy peer (other region/cluster/shard/replica). This is the canonical first move for a regional/shard asymmetry: it stops the bleeding immediately and is trivially reversible. When the asymmetry detector in step 4 has identified a healthy peer, recommend the traffic-shift as action #1, then escalate the root-cause investigation (config/GitOps drift, the failing dependency) to a human as the parallel follow-up.
    • Feature-flag off the failing code path, if a flag exists.

    Every other option — contacting an external provider, irreversible config/RBAC/state changes, anything touching the failing slice directly — is surfaced as an alternative for the human to approve, not executed by the agent. The principle (from FAILURE_MODES M1): outside-reference-paths means low confidence in root cause, so escalate the root cause; it does not forbid the safe, reversible mitigation that an on-call would reach for first.

  • If T0 was flagged as ambiguous in step 1, the top recommended action is "re-run the investigation with a widened window". Only after that re-run identifies a fuller change surface should any irreversible mitigation (rollout undo, RBAC change, cluster/infra rollback) be recommended.

  • If the implicated change has bundle_size > 1 (multiple changes shipped in one rollout), rollout undo remains the top recommendation but requires explicit human approval before execution. Surface the asymmetry explicitly: "the rollback reverts N changes when the incident affects only K of them".

  • If the classification is "cascading-failure", the top action is to break the amplification loop at its source, not to undo a rollout. A pure cascade typically has no rollout in the window (the trigger is a degraded dependency, not a deploy), so there is nothing to revert. Recommend, in order: open the circuit breaker on / shed load from the degraded dependency itself (the root of the cascade), then cap or disable the retry budget at the callers driving the retry storm. Shedding the callers' retries alone treats the symptom (the amplification) while leaving the degraded dependency saturated; opening the circuit at the dependency stops the loop at its origin and lets the dependency recover.

Standard mitigation order (applies when the constraints above do not fire):

  1. kubectl rollout undo the workload identified in step 2, if one rollout is clearly implicated and reversible (or revert the implicated ConfigMap / RBAC change).
  2. Feature-flag off the failing code path, if a flag exists.
  3. Scale the saturated resource (kubectl scale / raise the HPA ceiling / raise resources.limits), if the path is capacity-bound and not regression-bound.
  4. Traffic-shift away from the failing region / cluster / Service version / shard.
  5. Manual intervention (kubectl delete pod to force a fresh restart, kill a stuck Job) as a last resort, with explicit acknowledgement that it does not address cause — pods will recreate from the same broken spec.

If no safe mitigation exists even after applying the above, surface that explicitly and escalate.

7. Hand off

Produce a structured handoff for postmortem-author. All four elements below are mandatory and must appear as labelled sections, even when an element is empty (write "Open questions: none identified", not nothing — a silently missing section reads as "investigation incomplete" to the next responder):

  • Timeline (T0, key events, mitigation timestamp, Tresolved).
  • Ranked hypotheses with the evidence supporting each.
  • Mitigation taken / recommended and observed effect.
  • Open questions (gaps in signals, unverified assumptions, root-cause threads the mitigation did not close). This section is the most-often dropped and the most valuable to the postmortem: list every unresolved thread explicitly. If the investigation truly left no gaps, say so explicitly rather than omitting the heading.

Output format

The agent's final message in any invocation must include:

  1. Anchored window: T0 = ..., Tnow = ....
  2. Change surface: bulleted list of overlapping changes (rollouts, ConfigMap/Secret, RBAC, HPA/cluster, CronJobs), or "no changes in window".
  3. Classified path: one of the four, or "outside reference paths" with justification.
  4. Confirming signals: three or more, each cited with source.
  5. Blast radius: users + surfaces + business impact.
  6. Recommended mitigation: ordered, with explicit "do not address cause" notes where applicable.
  7. Handoff payload: structured for postmortem-author, containing all four labelled sections from step 7 — timeline, ranked hypotheses, mitigation, and open questions. Do not collapse or omit any of them; an absent "open questions" section is treated as an incomplete handoff.

Worked examples

Eleven end-to-end examples are committed under examples/, each with fixtures and a runnable replay test.

Reference paths (one canonical example per path):

Escalation cases (exercise the FAILURE_MODES.md rules):

Edge / boundary cases:

The examples mirror the seven methodology steps so contributors can see the methodology in motion, not just described.

Replay tests

Every example has a replay test in tests/ that runs the methodology against committed fixtures, with no external credentials (no live cluster needed). Run from the skill directory:

for t in tests/replay_*.py; do python "$t" || exit 1; done

The 11 tests cover the four reference paths, the FAILURE_MODES.md escalation rules (M1, M2, M3, M4), and the edge cases (zero changes, multi-region asymmetry, capacity saturation). Tests exit non-zero if the methodology produces the wrong classification, mitigation, or escalation against known-good fixtures. See tests/README.md for the fixture schema and how to add a new replay test.

Failure modes

This skill is wrong in predictable ways. Read FAILURE_MODES.md before relying on it for production triage. Highlights:

  • The four reference paths cover most but not all Kubernetes incidents; novel failure shapes get force-fit if the agent does not check step 4 carefully.
  • Anchoring on the wrong T0 produces a confidently wrong change-surface bisection.
  • The mitigation recommendation is not a substitute for a human approver on changes with broad blast radius.

Anyshift integration (opt-in)

The methodology above runs end-to-end with whatever telemetry, rollout/event source, and RBAC audit log you already have for your cluster (kubectl, the Kubernetes events API, kube-state-metrics, Prometheus). No Anyshift dependency.

The Anyshift MCP can act as a context primer for step 2 (change surface) by exposing a versioned resource graph that links rollouts, RBAC changes, and cluster/infrastructure changes to the specific Kubernetes resources implicated in the incident. See the per-skill README for the measured "with vs without" delta on the OOM and DNS examples (published once the integration has been exercised against the replay tests).

Files (sre-skills)
  • examples
    • 01-oom-cascade.md 7.4 KB
      # Worked example 1: OOM cascade in `payments-api`
      
      A realistic OOM incident in a payment processing service, triggered by a deploy that increased per-request memory footprint. Mirrors the seven methodology steps in [`../SKILL.md`](../SKILL.md). The fixtures and replay test under `../fixtures/01-oom-cascade/` and `../tests/replay_01_oom_cascade.py` exercise this example end-to-end.
      
      ## Scenario
      
      - **Service**: `payments-api` (HTTP service, 8 pods, behind `api-gateway`).
      - **Deploy at 2026-03-12 14:18 UTC**: introduced webhook payload buffering. Per-request memory footprint went from ~80 MB to ~210 MB. Pod memory limit unchanged at 512 MB.
      - **Alert fires at 14:32 UTC**: `payments_api_error_rate > 5%`.
      - **Cascade**: at 14:30 the pods hit the memory limit, the kubelet starts `OOMKill`ing them, the Deployment's ReplicaSet restarts them, in-flight requests fail with `502`, the `api-gateway` retries failed requests (3 retries, 500 ms backoff), the retry storm doubles the incoming request rate, the still-recovering pods OOM faster, error rate climbs from 0.4% to 47% in 90 seconds.
      
      ## Step 1: anchor the window
      
      Earliest unambiguous symptom is the alert at `2026-03-12T14:32:00Z`. No customer reports filed yet. T0 set there.
      
      - **T0**: `2026-03-12T14:32:00Z`
      - **Tnow**: `2026-03-12T14:36:00Z` (investigation triggered four minutes after the alert)
      - **Window**: `[14:17:00Z, 14:36:00Z]`
      
      The 15-minute lead-in is what catches the 14:18 deploy in step 2. Without it, the bisection in step 2 would return empty.
      
      ## Step 2: bisect the change surface
      
      Changes overlapping the window:
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | Deploy `payments-api@v4.18.0` | `14:18:14Z` | Merge commit `9f3a2c1`. Diff touches `internal/webhook/buffer.go` (new payload-buffering path). |
      | Cluster / HPA | (none) | | No node-pool / HPA / resource-limit changes in window. |
      | RBAC | (none) | | No Role / RoleBinding changes in window. |
      | ConfigMap / flags | (none) | | No flips in window. |
      | CronJob | (none) | | No batch jobs in window. |
      
      One change in window: the `payments-api` deploy 14 minutes before T0. Strong candidate for the deploy-correlator path. Need to check the OOM path in parallel (both paths can be true; OOM is often *triggered* by a deploy).
      
      ## Step 3: classify against the four reference paths
      
      Match against the four reference paths:
      
      - **OOM**: pod restart count climbed from 0 to 12 in the window. Pod / kubelet events show `OOMKilled` on 6 of 8 pods. Memory metric shows RSS at 510 MB (limit 512 MB) immediately before each OOMKill. Strong match.
      - **DNS**: no `NXDOMAIN` / `SERVFAIL` in logs, no `getaddrinfo` errors, outbound latency unchanged. No match.
      - **Cascading-failure**: retry count at `api-gateway` spiked 3.2x in the window. Latency on downstream `ledger-svc` calls unchanged. Cascade signature is present but is *downstream of* the OOM, not the root.
      - **Deploy-correlator**: 14:18 deploy is the only change in window, diff touches the failing surface (`internal/webhook/buffer.go` is in the request path), and the new code allocates ~130 MB more per concurrent request. Strong match.
      
      Classification: **OOM**, triggered by the deploy-correlator path. The cascading-failure signature is a second-order effect (retry storm amplifying OOM pressure), not an independent path.
      
      ## Step 4: confirm with three independent signals
      
      Three independent signals supporting the OOM-via-deploy hypothesis:
      
      1. **Pod / kubelet events** (`fixtures/01-oom-cascade/pod_events.jsonl`): `OOMKilled` on 6 of 8 pods between 14:30 and 14:35.
      2. **Metrics** (`fixtures/01-oom-cascade/metrics.json`): RSS at 510 MB at 14:30:12Z, immediately before the first OOMKill.
      3. **Deploy diff** (`fixtures/01-oom-cascade/deploys.json`): commit `9f3a2c1` adds a `WebhookBuffer` struct that holds full payload bodies in memory; the per-request memory increase aligns with the observed RSS jump.
      
      A fourth signal corroborates the cascade as second-order: **traces** (`fixtures/01-oom-cascade/traces.jsonl`) show retry-storm spans from `api-gateway` only after the first OOMKill, not before.
      
      Hypothesis confidence is high. Three signals, three independent sources.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: 100% of payment requests fail or hit retry latency. Read-only endpoints (`GET /payments/:id`) are also affected because they share the same pods.
      - **Surfaces affected**: all payment endpoints (`POST /charge`, `POST /refund`, `GET /payments/:id`, webhook callbacks).
      - **Business impact**: payment processing offline. SLO burn at 12x normal rate. In a real incident, the agent should also pull the revenue-per-minute figure from the finance dashboard the user has on hand.
      
      ## Step 6: propose mitigation before root cause
      
      Ordered recommendation:
      
      1. **`kubectl rollout undo`** `payments-api` to `v4.17.4` (the previous successful ReplicaSet). The deploy is the clearly implicated change, the rollback is one `kubectl rollout undo deployment/payments-api`, and the OOM behavior should clear within one pod-restart cycle (~30 s).
      2. **ConfigMap-flag off** the webhook buffering path. Not applicable: the change shipped without a flag, which is itself a process gap to flag in the postmortem.
      3. **Raise `resources.limits.memory`** from 512 MB to 1 GB as a stopgap if the rollback is delayed. Acknowledged that this does not address cause and will roughly double per-pod cost.
      4. **Traffic-shift** to a previous region: not applicable, single-region service.
      5. **Manual intervention** (`kubectl delete pod` to force fresh restarts): only if rollback and the limit bump are both blocked. Does not address cause; pods will OOM again within minutes.
      
      Recommended action: **`kubectl rollout undo` to `v4.17.4`**.
      
      ## Step 7: hand off
      
      Handoff payload for `postmortem-author`:
      
      ```json
      {
        "timeline": [
          {"t": "2026-03-12T14:18:14Z", "event": "Deploy payments-api@v4.18.0 (commit 9f3a2c1)"},
          {"t": "2026-03-12T14:30:12Z", "event": "First OOMKill on pod payments-api-7d9c-x4k2"},
          {"t": "2026-03-12T14:32:00Z", "event": "T0: alert payments_api_error_rate > 5% fires"},
          {"t": "2026-03-12T14:33:45Z", "event": "api-gateway retry count 3.2x baseline"},
          {"t": "2026-03-12T14:36:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "OOM (deploy-triggered)",
            "confidence": "high",
            "evidence": ["OOMKilled events on 6/8 pods", "RSS at memory limit", "deploy diff adds ~130MB/req via WebhookBuffer"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Revert payments-api to v4.17.4",
        "open_questions": [
          "Why did the new WebhookBuffer path ship without a memory-footprint review?",
          "Why is there no per-request memory guardrail in the payment service load tests?"
        ]
      }
      ```
      
      ## Why this is the OOM reference path
      
      This example is the reference because it shows all four of these in one incident:
      
      - A clear OOM signature at the pod level.
      - A clear deploy-correlator signature (one change in window, on the failing surface).
      - A clear cascading-failure signature as a second-order effect (api-gateway retry storm amplifying the primary failure).
      - A safe mitigation (revert) that comes before root cause.
      
      The methodology handles the layering: classify the primary path, note the cascade as second-order, recommend the revert. Skipping any of steps 1 through 4 produces a wrong answer (most commonly: classifying as cascading-failure and recommending circuit-breaker tuning, which does nothing to fix the OOM).
      
    • 02-dns-resolution-failure.md 7.1 KB
      # Worked example 2: DNS resolution failure in `inventory-svc`
      
      A realistic DNS incident in an internal HTTP service caused by a typo in a CoreDNS `Corefile` ConfigMap update. Mirrors the seven methodology steps in [`../SKILL.md`](../SKILL.md). Fixtures and replay test under `../fixtures/02-dns-resolution-failure/` and `../tests/replay_02_dns.py`.
      
      ## Scenario
      
      - **Service**: `inventory-svc` (HTTP service, calls upstream `catalog-svc.internal:8080`).
      - **Infra change at 2026-04-08 09:32 UTC**: `Corefile` ConfigMap updated to add a new `.internal` forward. The change introduced a typo in the upstream resolver IP, breaking resolution for `*.internal` zones intermittently (about 30% of queries returned `SERVFAIL`).
      - **Alert fires at 09:47 UTC**: `inventory_svc_error_rate > 3%`.
      - **No code deploy in window**. The change surface in step 2 contains only the ConfigMap update.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-04-08T09:47:00Z` (alert fire).
      - **Tnow**: `2026-04-08T09:53:00Z` (investigation triggered).
      - **Window**: `[09:32:00Z, 09:53:00Z]`.
      
      The 15-minute lead-in catches the 09:32 ConfigMap update. Without it, the change surface would return empty and the agent would risk misclassifying as a flaky-network external failure.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none) | | No `inventory-svc` Deployment rollouts in window. |
      | Cluster / HPA | `kube-system/coredns` Corefile ConfigMap updated | `09:32:18Z` | Added `forward .internal 10.100.0.53` line; typo: should be `10.100.0.5`. |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      One change in window: a cluster-level change to CoreDNS configuration. The fact that *zero* Deployment rollouts touched `inventory-svc` is itself a signal pushing away from the deploy-correlator path and toward an environmental cause.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: no `OOMKilled` events, memory metrics flat at baseline. No match.
      - **DNS**: 47 `SERVFAIL` log lines for `catalog-svc.internal` in window, `getaddrinfo: Name or service not known` errors in client pod logs, recent change to CoreDNS configuration. Strong match.
      - **Cascading-failure**: retry counts elevated but pattern is intermittent (correlates with the ~30% SERVFAIL rate), not the saturating-thread-pool shape of a true cascade. Match is weak; signature is downstream of DNS.
      - **Deploy-correlator**: no `inventory-svc` Deployment rollouts in window. No match against `inventory-svc`. The CoreDNS configuration *change* is structurally a "change in window touching the failing surface", flagged as a secondary explanation.
      
      Classification: **DNS**, triggered by a cluster-level change to CoreDNS. The agent does not need to invent a separate "config-correlator" path because the DNS path already encodes "recent change to the CoreDNS `Corefile` ConfigMap, kube-dns, `ndots`/search-domain, or a NetworkPolicy" as a confirming signal.
      
      ## Step 4: confirm with three independent signals
      
      1. **Logs** (`fixtures/02-dns-resolution-failure/logs.jsonl`): 47 `SERVFAIL` log lines from `inventory-svc` calling `catalog-svc.internal`, plus 8 `getaddrinfo` errors in `payments-api` (a second consumer of the same zone).
      2. **Metrics** (`fixtures/02-dns-resolution-failure/metrics.json`): DNS resolver error counter elevated 12x baseline. Application error rate at `inventory-svc` climbed from 0.2% to 3.4%. Memory and CPU flat.
      3. **Cluster change** (`fixtures/02-dns-resolution-failure/deploys.json`): `kube-system/coredns` ConfigMap updated 15 minutes before T0, diff touches the `.internal` zone forward configuration.
      
      A fourth signal: **traces** (`fixtures/02-dns-resolution-failure/traces.jsonl`) show failing spans with `dns.error=SERVFAIL` attribute, isolated to outbound calls to `*.internal` hostnames. Other outbound calls (e.g. to `api.stripe.com`) are unaffected. Asymmetric pattern is consistent with DNS scoped to one zone.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: ~30% of requests that depend on `catalog-svc` fail or hit retry latency. Read-only catalog browsing degraded; checkout still works because the order-creation path does not call `catalog-svc` synchronously.
      - **Surfaces affected**: all `inventory-svc` endpoints that hit `catalog-svc`, plus any other consumer of `*.internal` zones (at least `payments-api`).
      - **Business impact**: catalog browsing degraded across the platform. SLO burn at 8x normal rate for read endpoints.
      
      ## Step 6: propose mitigation before root cause
      
      1. **Revert** the `kube-system/coredns` ConfigMap to the version from `09:30:00Z`. The change is the clearly implicated single change in window, and reverting it takes one `kubectl apply` of the previous ConfigMap.
      2. **Feature-flag off**: not applicable. No ConfigMap flag controls DNS resolution.
      3. **Scale up**: not applicable. The failure is not capacity-bound (no `kubectl scale` / HPA ceiling / `resources.limits` change would help).
      4. **Traffic-shift**: not applicable in this single-cluster topology. If the cluster had a sibling cluster on a different DNS configuration, shifting traffic there would be a viable stopgap.
      5. **Manual intervention**: restarting CoreDNS pods does not help because they reload from the same broken ConfigMap. Restarting client pods does not help because they will hit the same resolver. Skip.
      
      Recommended action: **revert the `kube-system/coredns` ConfigMap**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-04-08T09:32:18Z", "event": "kube-system/coredns ConfigMap updated (typo in .internal forward IP)"},
          {"t": "2026-04-08T09:34:00Z", "event": "First SERVFAIL log line from inventory-svc"},
          {"t": "2026-04-08T09:47:00Z", "event": "T0: alert inventory_svc_error_rate > 3% fires"},
          {"t": "2026-04-08T09:53:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "DNS (infra-change-triggered)",
            "confidence": "high",
            "evidence": ["47 SERVFAIL log lines in window", "DNS resolver error counter 12x baseline", "CoreDNS ConfigMap change 15min before T0 touches .internal zone", "outbound calls to non-.internal hosts unaffected"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Revert kube-system/coredns ConfigMap to the 09:30:00Z version",
        "open_questions": [
          "Why did the ConfigMap change ship without a syntax-check or a staged rollout?",
          "Why is there no end-to-end DNS resolution health check on the cluster's internal zones?"
        ]
      }
      ```
      
      ## Why this is the DNS reference path
      
      - The failure shape is intermittent (not 100%), which is the signature DNS issues often present and which the OOM and deploy-correlator paths cannot explain.
      - The change surface contains an infrastructure change rather than a code deploy, which forces the agent to widen its mental model beyond "what code shipped".
      - The signal asymmetry (outbound calls to `*.internal` fail, calls to `*.com` work) is diagnostic for DNS scoped to a zone, and would be invisible without the trace-level attribute.
      
      Skipping step 4 (three independent signals) is the most likely failure mode here: an agent that classifies on logs alone may over-attribute the failure to the consumer service rather than the resolver.
      
    • 03-cascading-failure-retry-storm.md 8.2 KB
      # Worked example 3: cascading failure from upstream slowdown
      
      A realistic cascading-failure incident: an upstream dependency (`ledger-svc`) slows down due to a DB query plan flip, the caller (`payments-api`) saturates its thread pool waiting for it, and the API gateway retry budget amplifies the failure into a wider error rate. **No code deploy, no DNS, no OOM, no infra change in window.** Mirrors the seven methodology steps in [`../SKILL.md`](../SKILL.md). Fixtures and replay test under `../fixtures/03-cascading-failure-retry-storm/` and `../tests/replay_03_cascade.py`.
      
      ## Scenario
      
      - **Service**: `payments-api` (depends synchronously on `ledger-svc`).
      - **Upstream**: `ledger-svc`'s P99 latency drifts from ~50 ms to ~180 ms over the morning; at 11:00 UTC it crosses a knee in the query-planner cost model and jumps to ~620 ms. The underlying cause is a DB table that grew past the index-only threshold, not a code change. No `ledger-svc` deploy in days.
      - **Cascade**: `payments-api`'s thread pool (50 workers, ~50 ms baseline per request) saturates once requests start taking ~600 ms. Queue depth grows. New requests sit in queue then fail with `503 service unavailable`. The `api-gateway` retries (up to 3, 500 ms backoff). Retries hit the still-saturated pool, amplifying load.
      - **Alert at 11:08 UTC**: `payments_api_latency_p99 > 1000ms`.
      - The methodology must classify this as **cascading-failure** and recommend **circuit-breaker / traffic-shift**, not revert. There is no change to revert.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-03-20T11:08:00Z` (alert fire).
      - **Tnow**: `2026-03-20T11:15:00Z`.
      - **Window**: `[10:53:00Z, 11:15:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none) | | No rollouts in window. Most recent `payments-api` deploy was 9 days ago. |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      **Zero changes in window.** Per the methodology, this is itself a strong signal: the failure is likely external (capacity, dependency, upstream provider) rather than a self-inflicted regression. The agent should *not* hunt for a phantom deploy and *should* widen the dependency-health check.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: `payments-api` RSS p95 flat at ~210 MB against a 512 MB limit. No `OOMKilled` events. No match.
      - **DNS**: no `SERVFAIL` / `getaddrinfo` errors in logs. Outbound calls resolve normally. No match.
      - **Cascading-failure**: `payments-api` thread-pool saturation warnings in logs, queue depth growing, P99 latency tripled, `api-gateway` retry rate spiked from baseline to 8x. Upstream `ledger-svc` P99 latency jumped from ~50 ms to ~620 ms inside the window. **Strong match.**
      - **Deploy-correlator**: no deploy in window. No match.
      
      Classification: **cascading-failure**. The root degradation is upstream (`ledger-svc`); the failure propagates through `payments-api` thread-pool saturation and is amplified by gateway retries.
      
      ## Step 4: confirm with three independent signals
      
      1. **Metrics** (`fixtures/03-cascading-failure-retry-storm/metrics.json`): `upstream_latency_p99_ms` (the latency `payments-api` observes calling `ledger-svc`) climbed from 52 ms at 10:53 to 624 ms at 11:08. `payments_api_latency_p99` followed it up to 1170 ms. `gateway_retry_rate_rps` spiked from 6 baseline to 48 at T0 (8x).
      2. **Application logs** (`fixtures/03-cascading-failure-retry-storm/logs.jsonl`): repeated `thread pool saturated, queue depth 142` warnings from `payments-api` starting at 11:03; `ledger client request timeout` errors from 11:04 onward; gateway 503 + retry attempt logs.
      3. **Traces** (`fixtures/03-cascading-failure-retry-storm/traces.jsonl`): the slow span is consistently the `ledger-svc` hop. Pre-window baseline traces show `ledger-svc` at ~50 ms; in-window traces show 580 to 920 ms on the same span, with `payments-api` accumulating queued requests behind it.
      
      Hypothesis confidence is high. Three independent signal sources, plus a fourth from the change-audit channel: "no changes in window" is itself a positive signal that the failure is environmental, not regression-driven.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: P99 latency crossed 1 s for all payment requests. Around 12% of requests time out at the gateway after exhausting retry budget. Roughly 88% complete, but slowly.
      - **Surfaces affected**: every endpoint on `payments-api` that touches the `ledger-svc` synchronous path (which is most of them). Downstream consumers of `payments-api` see slower responses; some upstream callers degrade silently.
      - **Business impact**: latency-degraded payments. SLO burn on `payments-api` P99 latency SLO at ~6x normal. Revenue impact softer than the OOM example because most requests still complete.
      
      ## Step 6: propose mitigation before root cause
      
      There is no change to revert. The mitigation list is therefore different from a deploy-triggered incident.
      
      1. **Open the circuit breaker** on the `payments-api` → `ledger-svc` call path. Fail fast (return cached / degraded responses where possible) instead of letting requests queue and time out. Stops the retry storm from amplifying the upstream degradation. Does not fix `ledger-svc` itself.
      2. **Shed traffic** from `payments-api`: route non-critical payment operations (e.g. async batch jobs that hit `ledger-svc`) away from the production pool, or have the gateway prefer a healthy replica if one exists. Buys time while `ledger-svc` recovers.
      3. **Scale `ledger-svc` reads** (if the workload is read-heavy and the slowdown is on read paths) as a secondary stopgap. Acknowledged that this does not fix the underlying query-plan issue; it only adds capacity to absorb the slower per-request cost.
      4. **Revert**: not applicable, no change in window.
      5. **Manual intervention** (`kubectl rollout restart deployment/payments-api` to drain the saturated queue): only if circuit-breaker and traffic-shift are blocked. Does not address cause; the pool will saturate again within minutes if `ledger-svc` is still slow.
      
      Recommended action: **open the circuit breaker on the `ledger-svc` call path**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-03-20T10:53:00Z", "event": "ledger-svc upstream latency p99 ~52ms (baseline)"},
          {"t": "2026-03-20T11:00:00Z", "event": "ledger-svc latency knee, p99 jumps from 180ms to 480ms"},
          {"t": "2026-03-20T11:03:30Z", "event": "First 'thread pool saturated' warning from payments-api"},
          {"t": "2026-03-20T11:04:18Z", "event": "First 'ledger client request timeout' error"},
          {"t": "2026-03-20T11:08:00Z", "event": "T0: alert payments_api_latency_p99 > 1000ms fires"},
          {"t": "2026-03-20T11:15:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "Cascading-failure (upstream slowdown)",
            "confidence": "high",
            "evidence": ["upstream ledger-svc p99 climbed 52ms -> 624ms", "payments-api thread pool saturation in logs", "gateway retry rate 8x baseline", "no changes in window"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Open circuit breaker on payments-api -> ledger-svc call path",
        "open_questions": [
          "What caused the ledger-svc query-plan flip at ~11:00? Index bloat? Stats staleness? Table growth past planner threshold?",
          "Why did payments-api degrade rather than fail open? Should circuit-breaker default-state be reconsidered?",
          "Is there a tested traffic-shift path for payments-api, or is this the first time it's needed?"
        ]
      }
      ```
      
      ## Why this is the cascading-failure reference path
      
      - The "no change in window" signal is dispositive. An agent that biases toward "find the deploy that broke things" will hunt for a phantom deploy and waste time.
      - The cascade is detectable via *upstream latency growth* even when retry rate isn't dramatic, which is the second signal pattern the methodology's `_has_cascade_signature` looks for.
      - The mitigation ordering is genuinely different (circuit-breaker first, not revert), which the example proves the methodology produces correctly.
      
      Skipping step 2 here is the most likely failure mode: an agent that doesn't enumerate "zero changes" as a signal will misclassify the incident as outside-reference-paths and escalate when in fact the cascading-failure path is the clean fit.
      
    • 04-deploy-correlator-serialization.md 7.9 KB
      # Worked example 4: pure deploy-correlator (serialization regression)
      
      A realistic deploy-correlator incident: a deploy ships a serialization change that returns binary-encoded responses on an endpoint whose downstream consumers expect JSON. **Not OOM, not DNS, not a cascade.** A clean deploy-correlator signature: one change in window, on the failing surface, with the deploy diff explaining the failure mode. Mirrors the seven methodology steps in [`../SKILL.md`](../SKILL.md). Fixtures and replay test under `../fixtures/04-deploy-correlator-serialization/` and `../tests/replay_04_deploy_correlator.py`.
      
      ## Scenario
      
      - **Service**: `checkout-api`. The `GET /cart/:id` endpoint returns the user's current cart contents to multiple downstream consumers (web frontend, mobile apps, partner widgets).
      - **Deploy at 2026-02-15 13:12 UTC**: shipped a "performance" change that switched the `/cart/:id` response from JSON to a binary Protobuf encoding behind the same `Content-Type: application/json` header. The change was wrapped in a feature flag during development but the flag was removed in the final PR. None of the downstream consumers can decode Protobuf, so they fail to parse the response and surface errors back to users.
      - **Alert at 13:25 UTC**: `checkout_api_error_rate > 2%`.
      - **What the methodology must produce**: classify as **deploy-correlator** (not OOM, not DNS, not cascade), recommend reverting the deploy. The deploy diff explicitly touches the failing surface (`/cart/:id` serialization), which is the M4 confirmation-bias guard satisfied.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-02-15T13:25:00Z` (alert fire).
      - **Tnow**: `2026-02-15T13:30:00Z`.
      - **Window**: `[13:10:00Z, 13:30:00Z]`.
      
      The 15-minute lead-in catches the 13:12 deploy, 13 minutes before T0.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | Deploy `checkout-api@v6.4.0` | `13:12:08Z` | Commit `d1c4e22`. Diff: `internal/serializer/cart.go` switches response encoding from JSON to Protobuf for the `/cart/:id` endpoint. |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | The PR description mentions a flag was removed before merge. |
      | CronJob | (none) | | |
      
      One change in window. The diff explicitly touches the endpoint the consumers are calling, which is the M4 (deploy-correlator confirmation bias) guard: deploy-correlator is only the right classification when the deploy diff actually intersects the failing surface, not just because the timing lines up.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: RSS p95 flat at ~140 MB against a 512 MB limit. No `OOMKilled` events. No match.
      - **DNS**: no `SERVFAIL` / `getaddrinfo` errors. Outbound calls resolve normally. No match.
      - **Cascading-failure**: gateway retry rate goes from 3 rps baseline to ~5 rps after the deploy. That's a 1.7x increase, well below the cascade threshold (3x). Upstream latency unchanged (no slow dependency). No match.
      - **Deploy-correlator**: 13:12 deploy is the only change in window; diff touches `/cart/:id` serialization; downstream consumers report `unexpected EOF` / `failed to parse response` errors in the window. **Strong match.**
      
      Classification: **deploy-correlator**.
      
      ## Step 4: confirm with three independent signals
      
      1. **Metrics** (`fixtures/04-deploy-correlator-serialization/metrics.json`): `error_rate_pct` jumps from 0.3% baseline to 4.8% at T0; `request_rate_rps` flat (the failure is not amplifying traffic). `gateway_retry_rate_rps` only 1.7x baseline (no cascade signature). RSS flat (no OOM signature).
      2. **Logs** (`fixtures/04-deploy-correlator-serialization/logs.jsonl`): downstream consumer services log `unexpected EOF parsing JSON` and `failed to parse application/json response from checkout-api` starting 90 s after the deploy.
      3. **Deploy diff** (`fixtures/04-deploy-correlator-serialization/deploys.json`): commit `d1c4e22` switches the `/cart/:id` response encoding from JSON to Protobuf while keeping the `Content-Type: application/json` header.
      4. **Traces** (`fixtures/04-deploy-correlator-serialization/traces.jsonl`): `checkout-api` spans complete successfully (the service returns 200s); the failure is at the *consumer* span, which decodes the response and errors out. This is diagnostic: a server-side OOM or upstream-cascade incident would have failing spans on the `checkout-api` hop.
      
      Four independent signal sources. Confidence high.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: every user whose client calls `GET /cart/:id` and parses the response as JSON. Conservatively 100% of cart fetches, but the failure is silent for some clients that accept arbitrary bytes and only surface when the cart UI tries to render.
      - **Surfaces affected**: `/cart/:id` endpoint, plus any code path that depends on the cart payload being JSON-decodable. Web frontend cart page, mobile app cart screen, partner-widget cart integration. The endpoint itself responds 200.
      - **Business impact**: checkout funnel partially blocked (users cannot see their cart contents). SLO impact on the checkout success-rate SLO, not on the `checkout-api` availability SLO.
      
      ## Step 6: propose mitigation before root cause
      
      1. **`kubectl rollout undo`** `checkout-api` to `v6.3.7` (the previous successful ReplicaSet, commit `b09d318`). The deploy is the clearly implicated change, the rollback is one `kubectl rollout undo deployment/checkout-api`, and downstream consumers will start decoding successfully on the next request.
      2. **ConfigMap-flag off** the new serialization path. Not applicable: the PR description confirms the flag was removed before merge. Adding the flag back would itself be a code change with its own rollout cycle and is slower than rolling back.
      3. **`kubectl scale` / HPA**: not applicable. The failure is not capacity-bound.
      4. **Traffic-shift**: not applicable. No canary / blue-green / regional split is configured for this service.
      5. **Manual intervention** (kill request mid-flight): does not address cause. Skip.
      
      Recommended action: **`kubectl rollout undo` to `v6.3.7`**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-02-15T13:12:08Z", "event": "Deploy checkout-api@v6.4.0 (commit d1c4e22)"},
          {"t": "2026-02-15T13:13:42Z", "event": "First 'failed to parse application/json response' from web frontend"},
          {"t": "2026-02-15T13:25:00Z", "event": "T0: alert checkout_api_error_rate > 2% fires"},
          {"t": "2026-02-15T13:30:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "Deploy-correlator (serialization regression)",
            "confidence": "high",
            "evidence": ["deploy v6.4.0 in window touches /cart/:id encoding", "consumer-side parse errors in logs", "checkout-api spans return 200 (failure is consumer-side)", "no OOM / DNS / cascade signature"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Revert checkout-api to v6.3.7",
        "open_questions": [
          "Why was the feature flag removed in the final PR? Was it reviewed?",
          "Why didn't the contract test between checkout-api and its consumers catch the encoding switch?",
          "Are there other Content-Type-honest serialization paths in the codebase that could regress the same way?"
        ]
      }
      ```
      
      ## Why this is the deploy-correlator reference path
      
      - It is a *clean* deploy-correlator: no OOM, no DNS, no cascade. The methodology must reach deploy-correlator without being tempted into OOM-via-deploy (which is what example 01 tests).
      - It exercises the M4 confirmation-bias guard: the deploy isn't just temporally correlated; the diff explicitly touches the failing surface, and the failure shape (consumer-side parse errors with successful 200s upstream) matches the diff's stated change. An agent that classified on timing alone would be vulnerable.
      - It exercises the case where mitigation is unambiguously revert (one change, isolated, reversible), in contrast to example 07 where a bundled deploy makes revert blast radius unsafe.
      
    • 05-outside-reference-paths-third-party-rate-limit.md 7.8 KB
      # Worked example 5: outside reference paths (third-party rate limit)
      
      A failure that doesn't fit any of the four reference paths: a third-party payment provider rate-limits the platform's account. No OOM, no DNS, no internal cascade, no deploy. The methodology must **classify as outside-reference-paths and escalate** rather than force-fit one of the four canonical classifications. Exercises FAILURE_MODES.md rule M1. Mirrors the seven methodology steps in [`../SKILL.md`](../SKILL.md). Fixtures and replay test under `../fixtures/05-outside-reference-paths-third-party-rate-limit/` and `../tests/replay_05_outside_paths.py`.
      
      ## Scenario
      
      - **Service**: `payments-api` (a Kubernetes Deployment in your cluster) calls the external `api.stripe.com` for charge processing.
      - **Upstream**: at 2026-05-04 16:38 UTC, the payment provider's rate limiter starts returning `HTTP 429 Too Many Requests` on a fraction of charge requests. Their public status page shows no incident; account-level rate limits have changed silently. Around 35% of `POST /v1/charges` calls fail with 429.
      - **Alert at 16:44 UTC**: `payments_api_error_rate > 2%`.
      - **Methodology must produce**: classification `outside-reference-paths`, escalation flagged with reason M1 ("failure classified outside the four reference paths"). No mitigation recommendation beyond traffic-shift / feature-flag without a human approver.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-05-04T16:44:00Z`.
      - **Tnow**: `2026-05-04T16:50:00Z`.
      - **Window**: `[16:29:00Z, 16:50:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none) | | No `payments-api` Deployment rollouts in window. Most recent `payments-api` rollout was 3 days ago. |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      Zero changes in window. Per the methodology, this is itself a signal that the failure is likely external (upstream provider, certificate expiry, capacity drift) rather than self-inflicted. The agent should hunt for an *external* root cause.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: `payments-api` pod RSS p95 flat at ~190 MB against a 512 MB `resources.limits.memory`. No `OOMKilled` events. No match.
      - **DNS**: `api.stripe.com` resolves successfully (verified by the trace `dns_target` attribute on the failing spans). No `SERVFAIL` / `getaddrinfo` errors. No match.
      - **Cascading-failure**: gateway retry rate doubled (3 to 6 rps) but is below the 3x cascade threshold; the failure is at the upstream-provider hop, not in the internal dependency graph. No match for the cascade signature the methodology defines.
      - **Deploy-correlator**: no deploy in window. No match.
      
      Classification: **outside-reference-paths**. The signals support a third-party rate-limit hypothesis (HTTP 429 responses from `api.stripe.com` visible in logs and traces), but the methodology's four reference paths do not cover external-provider rate limiting.
      
      ## Step 4: confirm with three independent signals
      
      1. **Logs** (`fixtures/05-outside-reference-paths-third-party-rate-limit/logs.jsonl`): `upstream returned 429` errors from `payments-api` against `api.stripe.com`, with the `X-Rate-Limit-Remaining: 0` and `Retry-After` headers captured in the log.
      2. **Metrics** (`fixtures/05-outside-reference-paths-third-party-rate-limit/metrics.json`): `error_rate_pct` climbs from 0.3 to 4.2 across the window; internal `rss_bytes_p95` flat; `dns_resolver_errors_rps` flat at baseline.
      3. **Traces** (`fixtures/05-outside-reference-paths-third-party-rate-limit/traces.jsonl`): failing spans terminate at the `stripe` hop with `http.status_code=429` attribute. Other outbound calls (e.g. to internal services) succeed normally.
      
      Three independent sources. Hypothesis: external rate limit on the payment provider's API. Confidence is high *on the hypothesis*; the methodology is honest that the classification does not fit the four reference paths.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: roughly 35% of payment requests fail with the user-visible "could not process payment, please try again" error. Reads / other endpoints unaffected.
      - **Surfaces affected**: `POST /v1/charges` path only (the rate limit appears scoped to charges). Refunds and lookups still work.
      - **Business impact**: payment throughput cut by ~35%. SLO burn on `payments-api` error-rate SLO. Direct revenue impact for the duration.
      
      ## Step 6: propose mitigation before root cause
      
      Because the classification is outside the four reference paths, the methodology constrains the mitigation set: traffic-shift and feature-flag are allowed unilaterally; anything broader requires a human approver per the M1 escalation rule.
      
      1. **Revert**: not applicable, no change in window to revert.
      2. **Feature-flag off** the charge flow and surface a maintenance message to users. Allowed without escalation per M1, with the caveat that this is degraded service, not mitigation.
      3. **Traffic-shift**: not applicable unless a secondary payment provider is wired up. If one exists, route charges to it.
      4. **Scale**: not applicable. `kubectl scale` / raising the HPA ceiling / raising `resources.limits` would not help — the limit is external, not internal capacity.
      5. **Manual intervention**: not applicable.
      
      Anything beyond the above (e.g. contacting the provider, lifting the rate limit, retrying with backoff at higher concurrency) requires a human in the loop because the methodology cannot rule out making the situation worse (more requests against a rate limiter compounds the problem).
      
      Recommended next action: **escalate to a human** with the rate-limit hypothesis and the partial-mitigation options.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-05-04T16:38:14Z", "event": "First HTTP 429 from api.stripe.com on POST /v1/charges"},
          {"t": "2026-05-04T16:41:02Z", "event": "Error rate crosses 1%"},
          {"t": "2026-05-04T16:44:00Z", "event": "T0: alert payments_api_error_rate > 2% fires"},
          {"t": "2026-05-04T16:50:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "Outside reference paths (third-party rate limit)",
            "confidence": "high (on hypothesis), classified as outside-reference-paths",
            "evidence": ["HTTP 429 from api.stripe.com in logs and traces", "X-Rate-Limit-Remaining: 0 header", "no changes in window", "internal telemetry healthy (no OOM, no DNS, no cascade signature)"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Escalate to a human. Partial options: feature-flag charges off with maintenance message; contact provider; investigate retry/backoff strategy under human oversight.",
        "escalate_to_human": true,
        "escalation_reasons": ["M1: failure classified outside the four reference paths"],
        "open_questions": [
          "Did the provider change rate limits on our account silently, or did we cross a threshold via organic growth?",
          "Is there a secondary payment provider we can shift charges to?",
          "What does the provider's status page actually show, and is there a support channel response time we can quote?"
        ]
      }
      ```
      
      ## Why this is the outside-reference-paths reference example
      
      - It exercises the M1 escalation path explicitly. The methodology must refuse to force-fit the failure into OOM/DNS/cascade/deploy-correlator just because the agent is *capable* of producing a confident-looking answer.
      - It distinguishes between *hypothesis confidence* (high: 429s in logs + traces + headers) and *classification confidence* (low: outside the four reference paths). The handoff format makes that distinction explicit so the postmortem-author can carry it forward.
      - It models the partial-mitigation set: feature-flag and traffic-shift are allowed unilaterally; anything else requires a human. This is the methodology's safety net for incidents it does not fully understand.
      
    • 06-ambiguous-t0-slow-burn.md 7.9 KB
      # Worked example 6: ambiguous T0 (slow-burn memory leak)
      
      A failure where T0 is genuinely unclear: the operator says "the service has been slow all morning", error rate has been creeping up for hours, no single alert fire-time pinpoints the start. The methodology must **flag T0 as ambiguous, classify with reduced confidence, and escalate with M2** so a human re-runs the investigation against a widened window. Exercises FAILURE_MODES.md rule M2. Fixtures and replay test under `../fixtures/06-ambiguous-t0-slow-burn/` and `../tests/replay_06_ambiguous_t0.py`.
      
      ## Scenario
      
      - **Service**: `recommendations-api`. Operator reports at 12:15 that "recs have been slow all morning, finally got around to looking".
      - **Underlying issue**: a memory leak introduced by a Deployment rollout three days ago. Pod RSS has been climbing steadily for ~72 hours, finally crossing the threshold where GC pauses started showing up around 09:30 today. Error rate ticked up from 0.3% to 1.4% over the morning, well below the 2% alert threshold.
      - **No alert fired**. The investigation was triggered manually by the operator.
      - **Methodology must produce**: classification (likely OOM, given the signature), but with the **t0_ambiguous flag set**, escalation reasons including **M2**, and the recommended mitigation must include a "re-check after widening window" step before any irreversible action.
      
      ## Step 1: anchor the window
      
      The operator-reported symptom ("slow all morning") does not pinpoint a single T0. Per SKILL.md step 1:
      
      > If T0 is ambiguous (e.g. "slow all morning"), pick the earliest unambiguous signal and note the ambiguity in the timeline. Do not silently round.
      
      Earliest unambiguous signal: the first GC pause warning at `2026-04-19T09:32:14Z`. T0 set there with `t0_ambiguous = true`.
      
      - **T0**: `2026-04-19T09:32:14Z` (earliest unambiguous signal, ambiguity flagged).
      - **Tnow**: `2026-04-19T12:15:00Z`.
      - **Window**: `[09:17:14Z, 12:15:00Z]`.
      
      The window is nearly 3 hours wide instead of the typical 20 to 30 minutes. The 15-minute lead-in is structurally insufficient: the actual triggering deploy is 3 days outside the window. The methodology must flag this.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none in window) | | Most recent `recommendations-api` rollout: `v3.8.0` at `2026-04-16T15:22:00Z`, ~70 hours before T0. **Outside the window.** |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none in window) | | |
      
      Zero changes in the window. **This is a misleading "zero changes" signal**: it suggests an environmental cause when the actual cause is a 3-day-old rollout whose effect built up slowly. The methodology's standard "no changes → external cause" pivot would be wrong here, which is exactly why M2 escalation matters.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: pod RSS p95 climbs from baseline ~120 MB to ~485 MB over the window (`resources.limits.memory` 512 MB). GC pause durations grow from ~20 ms to ~180 ms. No `OOMKilled` events yet, but the trajectory points there within an hour. **Strong match for OOM signature** (RSS >= 90% of limit).
      - **DNS**: no `SERVFAIL` / `getaddrinfo` errors. No match.
      - **Cascading-failure**: no cascade signature. Upstream latency flat. No match.
      - **Deploy-correlator**: no Deployment rollout in window. No match against the windowed surface (but the actual cause is a rollout 3 days outside the window).
      
      Classification: **OOM**. The signature is strong even with the ambiguous T0.
      
      ## Step 4: confirm with three independent signals
      
      1. **Metrics**: `rss_bytes_p95` 119 MB at 09:17 climbs to 485 MB at 12:15 (95% of the 512 MB `resources.limits.memory`). GC pause p99 from 22 ms to 184 ms across the same window.
      2. **Logs**: `GC pause exceeded soft threshold` warnings starting 09:32:14Z, increasing in frequency through the window.
      3. **Traces**: latency tail on `recommendations-api` spans grows from p99 ~80 ms at 09:30 to p99 ~310 ms at 12:00, consistent with GC pauses stealing serving time.
      
      Three signals, three sources. But all of them are consistent with a *slow* OOM trajectory, not a sudden one. The M2 escalation matters because the change that *caused* the leak is invisible to this investigation.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: error rate 1.4% at Tnow, slowly climbing. Latency degradation visible to all users of `recommendations-api`.
      - **Surfaces affected**: every endpoint on `recommendations-api`. Recommendation cards on home page, related items on product pages, email recommendations pipeline.
      - **Business impact**: degraded recommendations quality, lower click-through rate. No hard outage yet but trajectory points to OOMKills within the hour.
      
      ## Step 6: propose mitigation before root cause
      
      Because T0 is ambiguous and the actual triggering change is outside the window, the methodology's mitigation list has a critical addition: **re-check with a widened window before executing**.
      
      1. **Re-check with a widened window first.** Re-run the investigation with `T0 = T0 - 4h` or wider, until the change-surface bisection returns a meaningful candidate. The 3-day-old deploy will appear. *This must precede any irreversible mitigation.*
      2. **Revert the implicated change** with `kubectl rollout undo` once the widened-window investigation identifies it. The current investigation has no change to revert.
      3. **Scale up** by raising `resources.limits.memory` from 512 MB to 1 GB as a short-term stopgap to delay the OOMKill trajectory while step 1 runs. Acknowledged this does not address cause.
      4. **Manual intervention** (rolling restart of `recommendations-api` pods to reset RSS): only if step 3 is blocked. The leak will resume; this is bridge time, not mitigation.
      
      Recommended action: **re-run with widened window, then `kubectl rollout undo` the identified change**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-04-19T09:17:14Z", "event": "Window start (15 min before earliest unambiguous signal)"},
          {"t": "2026-04-19T09:32:14Z", "event": "T0 (ambiguous): first GC pause exceeded soft threshold"},
          {"t": "2026-04-19T10:45:00Z", "event": "Error rate crosses 1%"},
          {"t": "2026-04-19T12:15:00Z", "event": "Tnow: operator-triggered investigation"}
        ],
        "ranked_hypotheses": [
          {
            "path": "OOM (slow trajectory, cause outside window)",
            "confidence": "medium (T0 ambiguous; widened-window investigation required to identify causal change)",
            "evidence": ["RSS p95 reached 95% of limit", "GC pause p99 8x baseline", "latency tail growing"]
          }
        ],
        "t0_ambiguous": true,
        "escalate_to_human": true,
        "escalation_reasons": ["M2: T0 is ambiguous; re-run with a widened window before acting on the recommended mitigation"],
        "mitigation_taken": null,
        "mitigation_recommended": "Re-run investigation with widened window (T0 - 4h or more) to identify the causal change, then revert that change.",
        "open_questions": [
          "When did the leak actually start? Likely deploy v3.8.0 at 2026-04-16T15:22:00Z, but confirm with widened-window run.",
          "Is there a per-request memory allocation chart that would distinguish a leak from organic growth?",
          "Why did no alert fire in the 3-day climb? Is the alert threshold (2% error rate) the wrong shape for a slow leak?"
        ]
      }
      ```
      
      ## Why this is the ambiguous-T0 reference example
      
      - It's the case where the methodology's *default* T0-picking heuristic ("first user-visible symptom") produces a window that misses the actual cause. Without M2 escalation, the methodology would confidently recommend "no change to revert, must be environmental" and be wrong.
      - It exercises the slow-trajectory vs. sudden-failure distinction. The OOM signature is present but evolving over hours, not minutes; the standard 15-minute lead-in is insufficient.
      - It models the gap between *what the operator can see* and *what the agent can act on*. Escalation is the safety net: the agent surfaces the partial picture, flags the gap, recommends a re-run.
      
    • 07-blast-radius-asymmetric-revert.md 8.1 KB
      # Worked example 7: blast-radius asymmetric revert (deploy bundle)
      
      A deploy bundle that shipped six unrelated changes in one release train. Only one of them is causing the incident, but reverting the bundle reverts all six. The methodology must **classify the path correctly and rank revert as the top mitigation**, but then **escalate with M3** because the revert's blast radius exceeds the incident's blast radius. Exercises FAILURE_MODES.md rule M3. Fixtures and replay test under `../fixtures/07-blast-radius-asymmetric-revert/` and `../tests/replay_07_blast_radius.py`.
      
      ## Scenario
      
      - **Service**: `notifications-svc` (a Kubernetes Deployment). Sends transactional emails, SMS, and push notifications; SMS goes through the external Twilio provider.
      - **Rollout at 2026-06-02 09:48 UTC**: a weekly release train rolled out a new ReplicaSet bundling six unrelated PRs (commit `8b3f4d1`, version `v8.12.0`):
        1. SMS provider integration update (Twilio API v2 migration).
        2. Email template refactor.
        3. Push notification batching optimization.
        4. Removal of deprecated SES region fallback.
        5. New /healthz endpoint.
        6. Dependency upgrades (express 4.x → 5.x).
      - **The actual failure**: the SMS integration update (PR 1 of 6) shipped with an invalid sender ID format that the new Twilio API rejects. SMS delivery is now failing 100% of the time. The other five changes are fine.
      - **Alert at 10:03 UTC**: `notifications_sms_error_rate > 50%`.
      - **Methodology must produce**: classification deploy-correlator (or close), `kubectl rollout undo` as the top mitigation, **escalation with M3** because rolling back `v8.12.0` reverts the other five working changes too.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-06-02T10:03:00Z`.
      - **Tnow**: `2026-06-02T10:10:00Z`.
      - **Window**: `[09:48:00Z, 10:10:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | Rollout `notifications-svc@v8.12.0` | `09:48:23Z` | Commit `8b3f4d1`. **Bundle of 6 PRs.** Diff summary lists Twilio v2 migration, email template refactor, push batching, SES fallback removal, /healthz, express 5.x upgrade. |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      One change in window, but the change is a bundle of six. This is the M3 trigger.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: pod RSS p95 flat at ~210 MB against the 512 MB `resources.limits.memory`. No `OOMKilled` events. No match.
      - **DNS**: no `SERVFAIL` / `getaddrinfo` errors. Outbound to Twilio resolves fine. No match.
      - **Cascading-failure**: no retry storm, no upstream-latency growth. The failure is at the external SMS provider hop with HTTP 400, not slowness. No match.
      - **Deploy-correlator**: rollout in window; diff explicitly touches the SMS path (Twilio API v2 migration line in the bundle); SMS error rate jumped from 0 to 100% within 90 seconds of the rollout. **Match.**
      
      Classification: **deploy-correlator**, specifically the Twilio API v2 migration PR within the bundle. The other five PRs are not implicated by any signal.
      
      ## Step 4: confirm with three independent signals
      
      1. **Logs** (`fixtures/07-blast-radius-asymmetric-revert/logs.jsonl`): `Twilio API rejected request: 400 invalid 'from' field, expected E.164 format` errors starting 09:49:51Z.
      2. **Metrics** (`fixtures/07-blast-radius-asymmetric-revert/metrics.json`): `sms_error_rate_pct` jumps from 0.0% baseline to 100% at 09:50; `email_error_rate_pct` and `push_error_rate_pct` flat (proving the failure is SMS-scoped, not service-wide).
      3. **Deploy diff** (`fixtures/07-blast-radius-asymmetric-revert/deploys.json`): bundle includes Twilio v2 migration line that matches the failing surface.
      4. **Traces** (`fixtures/07-blast-radius-asymmetric-revert/traces.jsonl`): SMS-sending spans terminate at the `twilio` hop with `http.status_code=400`; email-sending and push-sending spans complete successfully.
      
      Four independent signal sources. Confidence high.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: every user who would receive an SMS notification. Email and push channels are unaffected.
      - **Surfaces affected**: SMS sending only. Other notification channels healthy.
      - **Business impact**: SMS-dependent flows broken (2FA SMS, OTP, delivery alerts). Email and push fallbacks compensate partially for some flows but not for 2FA.
      
      **Revert blast radius**: `kubectl rollout undo` to `v8.11.4` would also roll back five working changes (email template refactor, push batching, SES fallback removal, /healthz, express 5.x). Two of those (the SES removal and express 5.x upgrade) have downstream consumers already depending on the new state. The rollback is asymmetric: it removes a 100%-broken feature (SMS) at the cost of regressing five healthy features.
      
      ## Step 6: propose mitigation before root cause
      
      1. **Revert** `notifications-svc` to `v8.11.4` with `kubectl rollout undo`. This is the methodology's top recommendation, BUT it carries the M3 caveat: blast radius exceeds the incident's blast radius. **Requires a human approver.**
      2. **Feature-flag off**: not applicable; the Twilio v2 migration shipped without a ConfigMap flag.
      3. **Scale**: not applicable.
      4. **Traffic-shift**: not applicable.
      5. **Manual intervention**: not applicable.
      
      A human-approved alternative the methodology surfaces but does not unilaterally recommend: **forward-fix** by shipping a targeted patch to the SMS sender ID format (a one-line fix), rather than rolling back the bundle. Forward-fix avoids the regression of the five healthy changes but introduces deployment-pipeline risk (the bundle's other changes have been live for ~15 min; rolling back and re-shipping the five working changes is operationally noisier).
      
      Recommended action: **`kubectl rollout undo` + escalate to human for blast-radius approval, with forward-fix surfaced as the cleaner alternative**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-06-02T09:48:23Z", "event": "Deploy notifications-svc@v8.12.0 (bundle of 6 PRs, commit 8b3f4d1)"},
          {"t": "2026-06-02T09:49:51Z", "event": "First Twilio 400 invalid 'from' field error"},
          {"t": "2026-06-02T10:03:00Z", "event": "T0: alert notifications_sms_error_rate > 50% fires"},
          {"t": "2026-06-02T10:10:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "Deploy-correlator (Twilio v2 migration within bundle)",
            "confidence": "high",
            "evidence": ["bundle diff includes Twilio v2 migration line", "sms-only error pattern, email/push unaffected", "Twilio 400 invalid 'from' field errors in logs", "traces fail at twilio hop"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Revert notifications-svc to v8.11.4 (M3 escalation flagged: bundle of 6 changes, revert reverts all six)",
        "alternative_mitigation": "Forward-fix: ship a targeted patch to the SMS sender ID format. Preserves the five healthy changes in the bundle but takes longer than revert.",
        "escalate_to_human": true,
        "escalation_reasons": ["M3: recommended revert affects 6 bundled changes, broader blast radius than the incident; requires a human approver before executing"],
        "open_questions": [
          "Why did the SMS sender ID format change land without a contract test against the Twilio v2 sandbox?",
          "Should the release train be split into one-PR-per-deploy for high-risk integration changes like SMS provider migrations?",
          "Is there a way to revert a single commit out of a bundle, or does the deploy pipeline require atomic version rollback?"
        ]
      }
      ```
      
      ## Why this is the asymmetric-revert reference example
      
      - It models a real-world operational tension: revert is often correct *at the methodology level* but unsafe *at the operational level* when the change being reverted is bundled. The methodology must surface this conflict, not gloss it.
      - It exercises M3 explicitly, with `bundle_size = 6` in the deploy fixture and a methodology that escalates when bundle size > 1 on a revert recommendation.
      - It models the *alternative-mitigation* slot in the handoff: when the top recommendation is conflicted, the methodology should surface a cleaner alternative (forward-fix) rather than only flagging the conflict and stopping.
      
    • 08-deploy-correlator-confirmation-bias.md 9.1 KB
      # Worked example 8: deploy-correlator confirmation bias (M4)
      
      A Deployment rollout and an RBAC change land in the same window. The rollout is innocent. The RBAC change is the actual cause. **The methodology must NOT classify as `deploy-correlator` from timing alone**: the rollout diff does not touch the failing surface, so it should not satisfy the M4 guard, and the classification falls through to `outside-reference-paths` with escalation. Exercises FAILURE_MODES.md rule M4. Fixtures and replay test under `../fixtures/08-deploy-correlator-confirmation-bias/` and `../tests/replay_08_confirmation_bias.py`.
      
      ## Scenario
      
      - **Service**: `users-api`, a Kubernetes Deployment in namespace `users`. Handles authentication via `/login` and profile management via `/profile`.
      - **Change 1, rollout**: `users-api@v9.1.0` at 2026-07-11 14:22 UTC. Diff: refactors the `/profile` page's avatar rendering to use a new CDN URL format. **Does not touch authentication or secret-retrieval code.**
      - **Change 2, RBAC**: at 2026-07-11 14:25 UTC, a platform-team change deleted the RoleBinding `users-api-secrets-reader`, which granted the `users-api` ServiceAccount `get` on Secrets in namespace `users`. The change was intended for a sibling cluster (operator error). Deleting the RoleBinding broke `users-api`'s ability to read its DB-password Secret from the Kubernetes API at startup.
      - **Failure surface**: `/login` (auth requires DB lookups which require the Secret) starts failing with `auth_failed: cannot read Secret` errors as pods cycle and the new pods can't read the Secret from the API.
      - **Alert at 14:33 UTC**: `users_api_error_rate > 2%`.
      - **Methodology must produce**: classification **NOT** `deploy-correlator` (the rollout diff doesn't touch the auth surface). Either `outside-reference-paths` with M1 escalation, surfacing the RBAC change as a high-confidence hypothesis.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-07-11T14:33:00Z`.
      - **Tnow**: `2026-07-11T14:38:00Z`.
      - **Window**: `[14:18:00Z, 14:38:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | Deploy `users-api@v9.1.0` | `14:22:14Z` | Commit `4e2c891`. Diff: `internal/profile/avatar.go` switches avatar CDN URL format. **No auth or secret code touched.** |
      | RBAC | RoleBinding deletion | `14:25:42Z` | Deleted RoleBinding `users-api-secrets-reader` granting the `users-api` ServiceAccount `get` on Secrets in namespace `users`. Operator note in audit log: "intended for sibling cluster". |
      | Cluster / HPA | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      **Two changes in window.** The rollout is closer to T0 in time, so an agent classifying on timing alone would over-weight it. The RBAC change is further from T0 but explicitly touches Secret access, which is in the auth path.
      
      ## Step 3: classify against the four reference paths
      
      The failing surface is authentication (`/login`). Failing-surface hints for this investigation: `secret`, `auth`, `rbac`.
      
      - **OOM**: RSS p95 flat. No `OOMKilled` events. No match.
      - **DNS**: no `SERVFAIL` errors. No match.
      - **Cascading-failure**: no cascade signature. No match.
      - **Deploy-correlator**: the rollout diff (`internal/profile/avatar.go`) does NOT match the failing-surface hints (`secret`, `auth`, `rbac`). The diff touches `/profile` rendering, not the failing surface. **M4 guard: classification fails despite timing correlation, because the methodology requires diff-touches-failing-surface evidence, not just temporal coincidence.**
      
      Classification: **outside-reference-paths**. The four reference paths do not cover RBAC-change-induced auth failures directly. The methodology surfaces the RBAC change as a high-confidence hypothesis but escalates rather than auto-recommending an RBAC revert.
      
      ## Step 4: confirm with three independent signals
      
      1. **Logs**: `auth_failed: cannot read Secret: secrets "users-api-db-password" is forbidden: User "system:serviceaccount:users:users-api" cannot get resource "secrets" in API group "" in the namespace "users" (403 Forbidden)` errors starting 14:26:08Z, immediately after the RBAC change.
      2. **Change audit**: the RBAC change at 14:25:42Z explicitly deleted the RoleBinding `users-api-secrets-reader` granting the `users-api` ServiceAccount `get` on Secrets; the audit log note "intended for sibling cluster" indicates operator error.
      3. **Traces**: failing spans terminate at the `kube-apiserver` hop with `http.status_code=403` / `error=Forbidden` when reading the Secret. `/profile` traces complete successfully (the rollout is healthy).
      4. **Metrics**: `error_rate_pct` jumps from baseline to ~3.4%; `auth_failure_count_rps` spikes from 0 to ~7 rps.
      
      Four independent signals. The hypothesis (the RBAC change broke Secret access) is high-confidence. The classification is outside-paths because the methodology does not have a dedicated RBAC-correlator reference path.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: every new login attempt fails. Existing sessions continue working until pods cycle.
      - **Surfaces affected**: `/login` and any path requiring fresh DB credentials. `/profile` (the surface the rollout touched) is healthy.
      - **Business impact**: new logins blocked. Customer-facing impact grows as existing sessions expire.
      
      ## Step 6: propose mitigation before root cause
      
      Because the classification is outside-paths, the methodology surfaces hypothesis-driven mitigation candidates but does not auto-recommend any of them:
      
      1. **Likely action** (requires human approval per M1): restore the deleted RoleBinding `users-api-secrets-reader` to grant the `users-api` ServiceAccount `get` on Secrets again. The audit log note ("intended for sibling cluster") confirms the change was misapplied; restoring it in this cluster is safe.
      2. **`kubectl rollout undo`**: NOT recommended. The rollout diff does not match the failing surface. Reverting it would be the deploy-correlator confirmation bias the M4 guard exists to prevent.
      3. **Feature-flag off**: not applicable (no flag).
      4. **Traffic-shift**: not applicable (namespace-wide RBAC scope).
      5. **Manual intervention**: not applicable.
      
      Recommended next action: **escalate to a human with the RBAC-restore hypothesis and the explicit note that the v9.1.0 rollout is NOT implicated despite the timing**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-07-11T14:22:14Z", "event": "Deploy users-api@v9.1.0 (commit 4e2c891, /profile avatar render change)"},
          {"t": "2026-07-11T14:25:42Z", "event": "RBAC change: RoleBinding users-api-secrets-reader deleted (get on Secrets in ns users; operator note: 'intended for sibling cluster')"},
          {"t": "2026-07-11T14:26:08Z", "event": "First 'auth_failed: cannot read Secret ... is forbidden (403 Forbidden)' error"},
          {"t": "2026-07-11T14:33:00Z", "event": "T0: alert users_api_error_rate > 2% fires"},
          {"t": "2026-07-11T14:38:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "RBAC-change-induced auth failure (outside reference paths)",
            "confidence": "high (on hypothesis), classified as outside-reference-paths",
            "evidence": ["RBAC change explicitly deleted RoleBinding users-api-secrets-reader (get on Secrets)", "auth_failed '403 Forbidden' Secret-read errors in logs", "/profile (rollout surface) traces healthy", "audit log note flags operator error"]
          },
          {
            "path": "Deploy-correlator (v9.1.0) - RULED OUT",
            "confidence": "low - rejected on M4 guard",
            "evidence_against": ["rollout diff touches /profile avatar rendering, not auth or Secret access", "/profile traces complete successfully", "failing surface is /login, not /profile"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Escalate. Likely action: restore the deleted RoleBinding users-api-secrets-reader (per audit log note, the change was misapplied). Do NOT run kubectl rollout undo: the rollout is not implicated.",
        "escalate_to_human": true,
        "escalation_reasons": ["M1: failure classified outside the four reference paths"],
        "open_questions": [
          "Why did the RBAC change land in this cluster instead of the sibling cluster? Process gap in the change-management tooling?",
          "Should the agent get a dedicated 'rbac-correlator' reference path? It is recurring enough to be reference-quality.",
          "Are there other workloads using the same RoleBinding that are about to fail when their pods cycle?"
        ]
      }
      ```
      
      ## Why this is the confirmation-bias reference example
      
      - It exercises M4 structurally: the agent has a rollout in window AND a failure to explain, but the rollout diff does not touch the failing surface. The methodology must resist the obvious-but-wrong classification.
      - It models the *ruled-out hypothesis* slot in the handoff: not just what the methodology recommends, but what it explicitly considered and rejected. This is what protects against an operator accidentally running `kubectl rollout undo` in haste.
      - It surfaces a gap the methodology has on purpose: there is no dedicated `rbac-correlator` reference path. The methodology is honest about this by classifying as outside-paths rather than force-fitting one of the four. Adding `rbac-correlator` is a methodology evolution question, not a bug.
      
    • 09-zero-changes-external-cert-expiry.md 6.3 KB
      # Worked example 9: zero changes in window (external TLS certificate expiry)
      
      A failure where the change-surface bisection returns empty and the actual cause is external: a partner's TLS certificate expired. Tests the methodology's pivot from "find the change" to "consider external causes" when step 2 returns zero. Fixtures and replay test under `../fixtures/09-zero-changes-external-cert-expiry/` and `../tests/replay_09_cert_expiry.py`.
      
      ## Scenario
      
      - **Service**: `webhook-receiver`, a Kubernetes Deployment whose pods call `partner.example.com` to register webhook subscriptions.
      - **Failure**: at 2026-08-20 03:00 UTC, the partner's TLS certificate expired. Outbound calls now fail with `tls: certificate has expired`. The partner has not yet rotated the cert.
      - **Alert at 03:18 UTC**: `webhook_receiver_error_rate > 4%`.
      - **No changes anywhere on our side**. The cert expiry happens at midnight UTC for whatever timezone the partner is in, with no announcement.
      - **Methodology must produce**: classification `outside-reference-paths`, the change-surface bisection should return zero, and the methodology should surface the "zero changes → external cause" pivot in its reasoning. Escalation with M1.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-08-20T03:18:00Z` (alert fire).
      - **Tnow**: `2026-08-20T03:25:00Z`.
      - **Window**: `[03:03:00Z, 03:25:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none) | | |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      **Zero changes in window.** Per SKILL.md step 2: "treat as a strong signal in itself: the failure is likely external (upstream provider, certificate expiry, DNS, capacity drift) rather than a self-inflicted regression."
      
      The methodology must explicitly enumerate this and pivot to external-cause hypotheses for step 3.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: RSS p95 flat. No `OOMKilled`. No match.
      - **DNS**: `partner.example.com` resolves successfully (`dns_target` attribute on traces shows valid IPs). The handshake fails *after* DNS, not at DNS. No DNS-classification match.
      - **Cascading-failure**: no internal upstream-latency growth, no retry-storm signature beyond a modest retry-budget increase. No match.
      - **Deploy-correlator**: no deploy. No match.
      
      Classification: **outside-reference-paths**. The failure shape (TLS handshake error, `certificate has expired` in error message) points to a third-party cert issue.
      
      ## Step 4: confirm with three independent signals
      
      1. **Logs**: `tls: certificate has expired` errors from `webhook-receiver` calling `partner.example.com`, starting 03:01:14Z (just after midnight UTC).
      2. **Traces**: failing spans terminate at the `webhook-receiver` → `partner` hop with `tls.error=certificate_expired`. The `dns_target` attribute resolves successfully; the failure is post-DNS.
      3. **Metrics**: error rate climbs from 0.2% baseline to 6.8% as queued webhook deliveries fail; internal RSS flat; DNS resolver error counter flat.
      
      Three independent signal sources. Hypothesis (external partner cert expiry) is high-confidence on the evidence even though the classification is outside-paths.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: every webhook subscription update to this partner. New subscriptions queue; existing connections to other partners work fine.
      - **Surfaces affected**: only the partner-specific webhook path.
      - **Business impact**: integration with this partner degraded. Other partners unaffected.
      
      ## Step 6: propose mitigation before root cause
      
      Outside-paths classification means the methodology constrains the mitigation set:
      
      1. **`kubectl rollout undo`**: not applicable; no rollout or change to revert.
      2. **Feature-flag off** the partner integration if a flag exists, surfacing a "partner integration paused" message to users. Allowed without escalation.
      3. **Traffic-shift** to a redundant partner endpoint if one is configured. Verify the endpoint actually bypasses the expired cert.
      4. **Scale**: not applicable.
      5. **Manual intervention**: not applicable.
      
      The actual remediation requires the *partner* to rotate the cert. We can only escalate and pause the integration on our side.
      
      Recommended next action: **escalate to a human with the cert-expiry hypothesis and the partner's incident contact details**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-08-20T03:01:14Z", "event": "First tls: certificate has expired error from partner.example.com"},
          {"t": "2026-08-20T03:18:00Z", "event": "T0: alert webhook_receiver_error_rate > 4% fires"},
          {"t": "2026-08-20T03:25:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "External: partner TLS certificate expired (outside reference paths)",
            "confidence": "high (on hypothesis), classified as outside-reference-paths",
            "evidence": ["tls: certificate has expired in logs", "TLS error post-DNS in traces", "zero changes on our side in window", "partner.example.com is the consistent failing target"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Escalate. Pause partner integration via feature flag if available. Contact partner's incident channel for cert rotation ETA.",
        "escalate_to_human": true,
        "escalation_reasons": ["M1: failure classified outside the four reference paths"],
        "open_questions": [
          "Does the partner have a cert-monitoring SLA they violated?",
          "Should we monitor partner cert expiry proactively (out-of-band check that warns 14 days before expiry)?",
          "Do we have a redundant partner endpoint with an independent cert chain?"
        ]
      }
      ```
      
      ## Why this is the zero-changes reference example
      
      - It validates SKILL.md step 2's claim that "zero changes is itself a strong signal." Without that pivot, an agent that always hunts for an internal cause would waste investigation time and possibly invent a phantom hypothesis.
      - It separates *DNS path* from *TLS issue*: both involve failing outbound calls, but the diagnostic shape is different (DNS fails at resolution; TLS fails after resolution). The example exercises the methodology's discipline on this distinction.
      - It models the case where remediation is fundamentally *out of our control*. The methodology's job in that case is to surface a clear hypothesis and stop, not to invent action.
      
    • 10-multi-region-asymmetry.md 7.1 KB
      # Worked example 10: multi-region asymmetry (config drift)
      
      The same image tag runs in two regional Kubernetes clusters. One cluster fails, the other does not. The methodology must **surface the regional asymmetry as a first-class signal** instead of force-fitting OOM/DNS/cascade on aggregate metrics that hide the asymmetry. Escalates with a regional-asymmetry reason and a config-drift hypothesis. Fixtures and replay test under `../fixtures/10-multi-region-asymmetry/` and `../tests/replay_10_multi_region.py`.
      
      ## Scenario
      
      - **Service**: `image-svc`. The same image tag (same code) runs in two regional clusters, `us-east-1` and `us-west-2`.
      - **Failure**: `us-east-1` error rate climbs from 0.3% to 25%. `us-west-2` stays at baseline (~0.3%). The image tags are identical, so it cannot be a code regression.
      - **Underlying cause** (which the methodology surfaces as a hypothesis, not as a confirmed root cause): per-cluster config drift. The `us-east-1` cluster's ConfigMap/GitOps overlay was never reconciled — an Argo CD sync failed silently and was never retried — leaving `image-svc` in that cluster pointing at a stale in-cluster object-store Service (`image-store`) that has no ready endpoints, while `us-west-2` points at the correct Service. The failure only surfaced today because of cache invalidation upstream.
      - **Alert at 11:42 UTC**: aggregate `image_svc_error_rate > 5%` (the aggregate is dragged up by `us-east-1`'s share of traffic).
      - **Methodology must produce**: detection of the per-region asymmetry, classification `outside-reference-paths` (no single reference path explains it), escalation with the regional-asymmetry reason, and a config-drift hypothesis in the handoff.
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-09-14T11:42:00Z`.
      - **Tnow**: `2026-09-14T11:50:00Z`.
      - **Window**: `[11:27:00Z, 11:50:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none) | | |
      | Cluster / HPA | (none in window) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      Zero changes in window. As with example 09, this points to an external or environmental cause. The regional asymmetry, surfaced in step 4, narrows the hypothesis space further.
      
      ## Step 3: classify against the four reference paths
      
      Run the classifier on aggregate metrics first:
      
      - **OOM**: aggregate RSS p95 flat. No `OOMKilled` events. No match.
      - **DNS**: the `image-store.storage.svc.cluster.local` Service name resolves; the failure is `no endpoints available`, not a DNS `SERVFAIL`. No match.
      - **Cascading-failure**: no cascade signature. No match.
      - **Deploy-correlator**: no rollout. No match.
      
      Classification: **outside-reference-paths**. The four reference paths do not have a `regional-asymmetry` shape.
      
      ## Step 4: confirm with three independent signals
      
      1. **Logs**: `image-svc` in `us-east-1` logs `image fetch failed: Get "http://image-store.storage.svc.cluster.local/o/raw": no endpoints available for service "image-store"`. `us-west-2` logs no such errors.
      2. **Metrics (regional)**: per-region samples (`fixtures/10-multi-region-asymmetry/metrics.json`) show `us-east-1` error rate 25% vs `us-west-2` error rate 0.3% over the window. **Regional asymmetry detector trips** with a 50x+ ratio.
      3. **Traces**: failing spans only originate from pods in the `us-east-1` cluster, terminating at the in-cluster `image-store` Service hop with `http.status_code=503` / `error=NoEndpoints`. `us-west-2` pods serve identical requests successfully.
      
      Three independent signal sources. The methodology's regional-asymmetry detector surfaces this as an additional structured signal in the handoff.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: roughly the share of traffic served by the `us-east-1` cluster (typically ~60% in this topology). Users routed to `us-west-2` are unaffected.
      - **Surfaces affected**: image processing endpoints in the `us-east-1` cluster.
      - **Business impact**: ~60% of image uploads fail. Read-only image fetches degraded.
      
      ## Step 6: propose mitigation before root cause
      
      Because the classification is outside-paths and a regional asymmetry is detected, the methodology's mitigation options shift:
      
      1. **Traffic-shift** all `image-svc` traffic to the `us-west-2` cluster while `us-east-1` is investigated. Allowed without escalation (it is the canonical "traffic-shift away from failing region" move).
      2. **Investigate config drift** in the `us-east-1` cluster. Compare the `image-svc` ConfigMap/GitOps overlay against `us-west-2` to identify the stale `image-store` Service reference, and check Argo CD sync status for the silently failed reconcile. This requires a human in the loop.
      3. **`kubectl rollout undo`**: not applicable (no rollout or change in window).
      4. **Scale**: not applicable.
      
      Recommended next action: **traffic-shift to the `us-west-2` cluster immediately**, then escalate the config-drift investigation to a human.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-09-14T11:32:00Z", "event": "First 'no endpoints available for service image-store' error in us-east-1"},
          {"t": "2026-09-14T11:42:00Z", "event": "T0: aggregate image_svc_error_rate > 5% fires"},
          {"t": "2026-09-14T11:50:00Z", "event": "Tnow: investigation triggered"}
        ],
        "ranked_hypotheses": [
          {
            "path": "ConfigMap/GitOps Service drift between us-east-1 and us-west-2 clusters (outside reference paths)",
            "confidence": "high (on hypothesis); classification outside-reference-paths",
            "evidence": ["per-region error rate 25% (us-east-1) vs 0.3% (us-west-2)", "'no endpoints available for service image-store' errors only from us-east-1 pods", "same image tag running in both clusters", "zero changes in window"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Traffic-shift to us-west-2 cluster while ConfigMap/GitOps Service-drift investigation runs.",
        "regional_asymmetry": {
          "detected": true,
          "per_region_peak_error_rate_pct": {"us-east-1": 25.0, "us-west-2": 0.3},
          "asymmetry_ratio": 83.3
        },
        "escalate_to_human": true,
        "escalation_reasons": [
          "M1: failure classified outside the four reference paths",
          "regional-asymmetry detected with 83x ratio"
        ],
        "open_questions": [
          "Why did the Argo CD sync for the us-east-1 cluster fail silently three weeks ago? Where is the missing sync-failed alert?",
          "Are other workloads in the us-east-1 cluster pointing at the same stale image-store Service, or otherwise unreconciled?",
          "Should config drift checks run as a daily cross-cluster GitOps/ConfigMap diff job?"
        ]
      }
      ```
      
      ## Why this is the multi-region-asymmetry reference example
      
      - It exposes the failure mode where *aggregate metrics hide the truth*. An agent that classifies on aggregate signals would see a moderate error rate and miss the diagnostic that only one cluster is affected.
      - It exercises the regional-asymmetry detector, which is the methodology's mechanism for surfacing per-region patterns as a first-class signal in the handoff.
      - It models a class of operational reality (ConfigMap/GitOps drift across clusters) that the four reference paths do not directly cover, requiring the methodology to escalate honestly rather than guess.
      
    • 11-capacity-bound-organic-growth.md 6.6 KB
      # Worked example 11: capacity-bound organic growth
      
      A failure with no change, no provider issue, no internal cascade. The service has been growing organically for weeks and finally crossed its capacity threshold. The methodology should **recommend scaling, not revert**, because there is no change to revert and the dominant signal is request-rate growth. Exercises the "scale_resource as primary mitigation" branch for outside-paths classifications with growing traffic. Fixtures and replay test under `../fixtures/11-capacity-bound-organic-growth/` and `../tests/replay_11_capacity_bound.py`.
      
      ## Scenario
      
      - **Service**: `search-svc` (search index queries).
      - **Pattern**: request rate has been climbing organically for ~3 weeks as a marketing campaign drove user growth. Capacity was sized for ~400 rps; today's morning peak hit 720 rps. The service's query queue starts rejecting requests when it overflows. No deploy, no DNS, no internal cascade.
      - **Alert at 10:55 UTC**: `search_svc_error_rate > 3%`.
      - **Methodology must produce**: classification `outside-reference-paths`, blast-radius detector flags `request_rate_growing=true`, and the mitigation list leads with **scale_resource** instead of revert (no change to revert).
      
      ## Step 1: anchor the window
      
      - **T0**: `2026-10-08T10:55:00Z`.
      - **Tnow**: `2026-10-08T11:05:00Z`.
      - **Window**: `[10:40:00Z, 11:05:00Z]`.
      
      ## Step 2: bisect the change surface
      
      | Source | Event | Time | Detail |
      |---|---|---|---|
      | Rollout | (none) | | |
      | Cluster / HPA | (none) | | |
      | RBAC | (none) | | |
      | ConfigMap / flags | (none) | | |
      | CronJob | (none) | | |
      
      Zero changes in window. Same pattern as examples 09 and 10: hunt for an external or environmental cause.
      
      ## Step 3: classify against the four reference paths
      
      - **OOM**: RSS p95 flat at ~290 MB against 512 MB limit (~57%). No `OOMKilled`. No match.
      - **DNS**: no resolution errors. No match.
      - **Cascading-failure**: no upstream-latency growth. No retry-storm pattern (the rejections are from the service itself, not from cascading retries). No match.
      - **Deploy-correlator**: no deploy. No match.
      
      Classification: **outside-reference-paths**. The signature is *request-rate growth* + *internal queue saturation*, which isn't one of the four reference paths.
      
      ## Step 4: confirm with three independent signals
      
      1. **Metrics**: `request_rate_rps` climbed from 412 at 10:40 to 718 at T0 (1.7x in 15 minutes); `error_rate_pct` from 0.3% to 4.6%; `queue_depth_p99` from 12 to 184 (15x); `rss_bytes_p95` flat at ~290 MB.
      2. **Logs**: `query queue full, rejecting request` warnings from `search-svc`, climbing in frequency through the window.
      3. **Traces**: failing spans show `error=queue_full` at the `search-svc` hop. The failing requests never reach the search index; they are rejected at admission.
      
      Three independent sources. The diagnostic is clean: the service is dropping load it cannot serve.
      
      ## Step 5: quantify blast radius
      
      - **Users affected**: ~4.6% of searches at T0, climbing as traffic climbs. Successful searches still complete normally (no latency degradation on accepted requests).
      - **Surfaces affected**: search query endpoint. All other endpoints healthy.
      - **Business impact**: degraded search quality (some users see "search temporarily unavailable"). No silent corruption or data loss.
      
      The `request_rate_growing` flag is `true` in the blast radius output (rate grew >= 1.5x across the window), which triggers the scaling mitigation branch.
      
      ## Step 6: propose mitigation before root cause
      
      Because the classification is outside-paths AND `request_rate_growing` is true, the methodology's mitigation list leads differently from a change-induced incident:
      
      1. **`kubectl scale` / raise the HPA ceiling** for `search-svc`. Request rate has climbed past current capacity headroom; the immediate fix is more replicas (`kubectl scale deployment/search-svc --replicas=N`, or raise the HPA `maxReplicas`). Acknowledged that capacity planning (forecasting, HPA tuning) is the root cause; scaling addresses the symptom.
      2. **`kubectl rollout undo`**: not applicable; no change to revert.
      3. **ConfigMap-flag off**: only as a temporary degradation play if scaling is delayed. Pausing low-value search features can free queue slots.
      4. **Traffic-shift**: only if a secondary search cluster exists. Same caveat as 09 and 10.
      5. **Manual intervention**: not applicable.
      
      Recommended action: **raise the HPA ceiling / `kubectl scale` `search-svc` now; queue capacity planning review as a follow-up**.
      
      ## Step 7: hand off
      
      ```json
      {
        "timeline": [
          {"t": "2026-10-08T10:40:00Z", "event": "request rate 412 rps (sustainable)"},
          {"t": "2026-10-08T10:48:00Z", "event": "request rate crosses 600 rps; first 'queue full' warning"},
          {"t": "2026-10-08T10:55:00Z", "event": "T0: alert search_svc_error_rate > 3% fires; request rate 692 rps"},
          {"t": "2026-10-08T11:05:00Z", "event": "Tnow: investigation triggered; request rate 718 rps, error rate 4.6%"}
        ],
        "ranked_hypotheses": [
          {
            "path": "Capacity-bound organic growth (outside reference paths)",
            "confidence": "high (on hypothesis), classified as outside-reference-paths",
            "evidence": ["request rate grew 412 -> 718 rps (1.7x) across window", "queue_depth_p99 grew 12 -> 184 (15x)", "query queue full warnings in logs", "no changes in window", "RSS / cpu / DNS / cascade signatures absent"]
          }
        ],
        "mitigation_taken": null,
        "mitigation_recommended": "Scale search-svc horizontally to absorb the new traffic level. Capacity planning review as a follow-up.",
        "escalate_to_human": true,
        "escalation_reasons": ["M1: failure classified outside the four reference paths"],
        "open_questions": [
          "Why did autoscaling not kick in? Are the autoscale thresholds tuned to absolute capacity instead of queue saturation?",
          "Is the 3-week request-rate climb visible in a forecast dashboard? If not, where would it have been caught?",
          "What is the upper bound on this service's horizontal scaling? Are downstream dependencies (search index shards) also at risk?"
        ]
      }
      ```
      
      ## Why this is the capacity-bound reference example
      
      - It models the case where the methodology's *default mitigation* (revert) is structurally wrong. There is no change to revert; recommending one would be a confident-looking error.
      - It exercises the `request_rate_growing` blast-radius signal, which is the methodology's mechanism for detecting saturation in the absence of a discrete event.
      - It surfaces a class of incidents where the underlying cause is operational (capacity planning) rather than software-defect. The methodology surfaces this honestly via outside-paths classification and scaling mitigation, instead of inventing a software hypothesis to explain the error rate.
      
  • fixtures
    • 01-oom-cascade
      • deploys.json 789 B
        {
          "service": "payments-api",
          "deploys": [
            {
              "service": "payments-api",
              "version": "v4.17.4",
              "commit": "a1b8e09",
              "deployed_at": "2026-03-10T09:14:22Z",
              "deployed_by": "ci-cd",
              "diff_summary": "internal/ledger/retry.go: increase ledger-svc retry budget from 2 to 3"
            },
            {
              "service": "payments-api",
              "version": "v4.18.0",
              "commit": "9f3a2c1",
              "deployed_at": "2026-03-12T14:18:14Z",
              "deployed_by": "ci-cd",
              "diff_summary": "internal/webhook/buffer.go: introduce WebhookBuffer that holds full payload bodies in memory before batch-writing. Per-request memory footprint increase from ~80MB to ~210MB."
            }
          ],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.3 KB · in bundle
      • metrics.json 1.3 KB
        {
          "service": "payments-api",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-03-12T14:17:00Z", "rss_bytes_p95": 83886080, "error_rate_pct": 0.3, "request_rate_rps": 412, "gateway_retry_rate_rps": 4},
            {"t": "2026-03-12T14:20:00Z", "rss_bytes_p95": 224395264, "error_rate_pct": 0.4, "request_rate_rps": 418, "gateway_retry_rate_rps": 5},
            {"t": "2026-03-12T14:25:00Z", "rss_bytes_p95": 398458880, "error_rate_pct": 0.4, "request_rate_rps": 421, "gateway_retry_rate_rps": 4},
            {"t": "2026-03-12T14:29:00Z", "rss_bytes_p95": 491782144, "error_rate_pct": 0.6, "request_rate_rps": 425, "gateway_retry_rate_rps": 6},
            {"t": "2026-03-12T14:30:00Z", "rss_bytes_p95": 510131200, "error_rate_pct": 0.8, "request_rate_rps": 427, "gateway_retry_rate_rps": 8},
            {"t": "2026-03-12T14:31:00Z", "rss_bytes_p95": 510655488, "error_rate_pct": 7.4, "request_rate_rps": 469, "gateway_retry_rate_rps": 41},
            {"t": "2026-03-12T14:32:00Z", "rss_bytes_p95": 511179776, "error_rate_pct": 24.1, "request_rate_rps": 612, "gateway_retry_rate_rps": 184},
            {"t": "2026-03-12T14:33:00Z", "rss_bytes_p95": 511441920, "error_rate_pct": 38.2, "request_rate_rps": 743, "gateway_retry_rate_rps": 312},
            {"t": "2026-03-12T14:34:00Z", "rss_bytes_p95": 511703040, "error_rate_pct": 46.8, "request_rate_rps": 821, "gateway_retry_rate_rps": 389}
          ]
        }
        
      • pod_events.jsonl 1.4 KB · in bundle
      • traces.jsonl 1.5 KB · in bundle
    • 02-dns-resolution-failure
      • deploys.json 706 B
        {
          "service": "inventory-svc",
          "deploys": [
            {
              "service": "inventory-svc",
              "version": "v2.9.1",
              "commit": "44e1b02",
              "deployed_at": "2026-04-07T16:11:09Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Bump catalog-svc client retry budget from 1 to 2"
            }
          ],
          "infra_changes": [
            {
              "resource": "kube-system/coredns",
              "kind": "ConfigMap",
              "changed_at": "2026-04-08T09:32:18Z",
              "changed_by": "platform-team",
              "diff_summary": "Add 'forward .internal 10.100.0.53' line. Note: previous resolver IP was 10.100.0.5; new line introduces a typo (.53 vs .5)."
            }
          ],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 2.4 KB · in bundle
      • metrics.json 1.3 KB
        {
          "service": "inventory-svc",
          "memory_limit_bytes": 268435456,
          "samples": [
            {"t": "2026-04-08T09:32:00Z", "rss_bytes_p95": 89128960, "error_rate_pct": 0.2, "request_rate_rps": 184, "gateway_retry_rate_rps": 2, "dns_resolver_errors_rps": 0.1},
            {"t": "2026-04-08T09:35:00Z", "rss_bytes_p95": 89653248, "error_rate_pct": 0.6, "request_rate_rps": 188, "gateway_retry_rate_rps": 3, "dns_resolver_errors_rps": 0.4},
            {"t": "2026-04-08T09:40:00Z", "rss_bytes_p95": 90177536, "error_rate_pct": 1.8, "request_rate_rps": 191, "gateway_retry_rate_rps": 7, "dns_resolver_errors_rps": 1.2},
            {"t": "2026-04-08T09:45:00Z", "rss_bytes_p95": 90439680, "error_rate_pct": 2.9, "request_rate_rps": 196, "gateway_retry_rate_rps": 11, "dns_resolver_errors_rps": 1.4},
            {"t": "2026-04-08T09:47:00Z", "rss_bytes_p95": 90439680, "error_rate_pct": 3.4, "request_rate_rps": 201, "gateway_retry_rate_rps": 13, "dns_resolver_errors_rps": 1.5},
            {"t": "2026-04-08T09:50:00Z", "rss_bytes_p95": 90701824, "error_rate_pct": 3.7, "request_rate_rps": 204, "gateway_retry_rate_rps": 14, "dns_resolver_errors_rps": 1.4},
            {"t": "2026-04-08T09:53:00Z", "rss_bytes_p95": 90963968, "error_rate_pct": 3.6, "request_rate_rps": 207, "gateway_retry_rate_rps": 14, "dns_resolver_errors_rps": 1.5}
          ]
        }
        
      • traces.jsonl 1.4 KB · in bundle
    • 03-cascading-failure-retry-storm
      • deploys.json 416 B
        {
          "service": "payments-api",
          "deploys": [
            {
              "service": "payments-api",
              "version": "v4.19.2",
              "commit": "5e7a112",
              "deployed_at": "2026-03-11T09:14:22Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Refactor invoice number formatting; no behavior change to ledger client."
            }
          ],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.4 KB · in bundle
      • metrics.json 1.7 KB
        {
          "service": "payments-api",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-03-20T10:53:00Z", "rss_bytes_p95": 219152384, "error_rate_pct": 0.2, "request_rate_rps": 384, "gateway_retry_rate_rps": 6, "upstream_latency_p99_ms": 52, "payments_api_latency_p99_ms": 118},
            {"t": "2026-03-20T10:58:00Z", "rss_bytes_p95": 219676672, "error_rate_pct": 0.4, "request_rate_rps": 388, "gateway_retry_rate_rps": 7, "upstream_latency_p99_ms": 121, "payments_api_latency_p99_ms": 184},
            {"t": "2026-03-20T11:00:00Z", "rss_bytes_p95": 219676672, "error_rate_pct": 0.4, "request_rate_rps": 391, "gateway_retry_rate_rps": 7, "upstream_latency_p99_ms": 482, "payments_api_latency_p99_ms": 546},
            {"t": "2026-03-20T11:03:00Z", "rss_bytes_p95": 219938816, "error_rate_pct": 1.2, "request_rate_rps": 394, "gateway_retry_rate_rps": 14, "upstream_latency_p99_ms": 588, "payments_api_latency_p99_ms": 712},
            {"t": "2026-03-20T11:05:00Z", "rss_bytes_p95": 220200960, "error_rate_pct": 2.9, "request_rate_rps": 401, "gateway_retry_rate_rps": 22, "upstream_latency_p99_ms": 604, "payments_api_latency_p99_ms": 884},
            {"t": "2026-03-20T11:08:00Z", "rss_bytes_p95": 220463104, "error_rate_pct": 7.4, "request_rate_rps": 419, "gateway_retry_rate_rps": 48, "upstream_latency_p99_ms": 624, "payments_api_latency_p99_ms": 1172},
            {"t": "2026-03-20T11:11:00Z", "rss_bytes_p95": 220725248, "error_rate_pct": 11.8, "request_rate_rps": 442, "gateway_retry_rate_rps": 71, "upstream_latency_p99_ms": 638, "payments_api_latency_p99_ms": 1418},
            {"t": "2026-03-20T11:15:00Z", "rss_bytes_p95": 220987392, "error_rate_pct": 12.4, "request_rate_rps": 451, "gateway_retry_rate_rps": 74, "upstream_latency_p99_ms": 641, "payments_api_latency_p99_ms": 1488}
          ]
        }
        
      • traces.jsonl 1.6 KB · in bundle
    • 04-deploy-correlator-serialization
      • deploys.json 803 B
        {
          "service": "checkout-api",
          "deploys": [
            {
              "service": "checkout-api",
              "version": "v6.3.7",
              "commit": "b09d318",
              "deployed_at": "2026-02-12T10:02:14Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Bump observability sdk minor version. No protocol or serialization changes."
            },
            {
              "service": "checkout-api",
              "version": "v6.4.0",
              "commit": "d1c4e22",
              "deployed_at": "2026-02-15T13:12:08Z",
              "deployed_by": "ci-cd",
              "diff_summary": "internal/serializer/cart.go: switch /cart/:id response encoding from JSON to Protobuf for performance. Content-Type header unchanged. Feature flag was removed before merge per PR notes."
            }
          ],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.4 KB · in bundle
      • metrics.json 1 KB
        {
          "service": "checkout-api",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-02-15T13:10:00Z", "rss_bytes_p95": 146800640, "error_rate_pct": 0.3, "request_rate_rps": 312, "gateway_retry_rate_rps": 3},
            {"t": "2026-02-15T13:13:00Z", "rss_bytes_p95": 147324928, "error_rate_pct": 0.4, "request_rate_rps": 314, "gateway_retry_rate_rps": 3},
            {"t": "2026-02-15T13:16:00Z", "rss_bytes_p95": 147587072, "error_rate_pct": 0.9, "request_rate_rps": 311, "gateway_retry_rate_rps": 4},
            {"t": "2026-02-15T13:20:00Z", "rss_bytes_p95": 148111360, "error_rate_pct": 2.1, "request_rate_rps": 309, "gateway_retry_rate_rps": 4},
            {"t": "2026-02-15T13:25:00Z", "rss_bytes_p95": 148373504, "error_rate_pct": 4.8, "request_rate_rps": 308, "gateway_retry_rate_rps": 5},
            {"t": "2026-02-15T13:28:00Z", "rss_bytes_p95": 148635648, "error_rate_pct": 6.1, "request_rate_rps": 310, "gateway_retry_rate_rps": 5},
            {"t": "2026-02-15T13:30:00Z", "rss_bytes_p95": 148897792, "error_rate_pct": 6.4, "request_rate_rps": 312, "gateway_retry_rate_rps": 5}
          ]
        }
        
      • traces.jsonl 1.3 KB · in bundle
    • 05-outside-reference-paths-third-party-rate-limit
      • deploys.json 390 B
        {
          "service": "payments-api",
          "deploys": [
            {
              "service": "payments-api",
              "version": "v4.20.1",
              "commit": "f81a307",
              "deployed_at": "2026-05-01T11:14:22Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Add charge audit logging. No protocol changes."
            }
          ],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.6 KB · in bundle
      • metrics.json 1.3 KB
        {
          "service": "payments-api",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-05-04T16:29:00Z", "rss_bytes_p95": 197132288, "error_rate_pct": 0.3, "request_rate_rps": 264, "gateway_retry_rate_rps": 3, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-05-04T16:35:00Z", "rss_bytes_p95": 197394432, "error_rate_pct": 0.4, "request_rate_rps": 268, "gateway_retry_rate_rps": 3, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-05-04T16:38:00Z", "rss_bytes_p95": 197656576, "error_rate_pct": 0.6, "request_rate_rps": 271, "gateway_retry_rate_rps": 4, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-05-04T16:41:00Z", "rss_bytes_p95": 197918720, "error_rate_pct": 1.2, "request_rate_rps": 274, "gateway_retry_rate_rps": 5, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-05-04T16:44:00Z", "rss_bytes_p95": 198180864, "error_rate_pct": 2.4, "request_rate_rps": 277, "gateway_retry_rate_rps": 5, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-05-04T16:47:00Z", "rss_bytes_p95": 198443008, "error_rate_pct": 3.7, "request_rate_rps": 281, "gateway_retry_rate_rps": 6, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-05-04T16:50:00Z", "rss_bytes_p95": 198705152, "error_rate_pct": 4.2, "request_rate_rps": 283, "gateway_retry_rate_rps": 6, "dns_resolver_errors_rps": 0.0}
          ]
        }
        
      • traces.jsonl 1.3 KB · in bundle
    • 06-ambiguous-t0-slow-burn
      • deploys.json 517 B
        {
          "service": "recommendations-api",
          "deploys": [
            {
              "service": "recommendations-api",
              "version": "v3.8.0",
              "commit": "7c2e441",
              "deployed_at": "2026-04-16T15:22:00Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Cache product features in-memory for warmup. Cache eviction left as TODO. (Outside the current investigation window; identified only via widened-window re-run.)"
            }
          ],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 862 B · in bundle
      • metrics.json 1.2 KB
        {
          "service": "recommendations-api",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-04-19T09:17:14Z", "rss_bytes_p95": 124780544, "error_rate_pct": 0.3, "request_rate_rps": 612, "gateway_retry_rate_rps": 4, "gc_pause_p99_ms": 22},
            {"t": "2026-04-19T09:45:00Z", "rss_bytes_p95": 198180864, "error_rate_pct": 0.4, "request_rate_rps": 614, "gateway_retry_rate_rps": 4, "gc_pause_p99_ms": 38},
            {"t": "2026-04-19T10:15:00Z", "rss_bytes_p95": 268435456, "error_rate_pct": 0.6, "request_rate_rps": 611, "gateway_retry_rate_rps": 5, "gc_pause_p99_ms": 67},
            {"t": "2026-04-19T10:45:00Z", "rss_bytes_p95": 339738624, "error_rate_pct": 1.1, "request_rate_rps": 615, "gateway_retry_rate_rps": 5, "gc_pause_p99_ms": 98},
            {"t": "2026-04-19T11:15:00Z", "rss_bytes_p95": 402653184, "error_rate_pct": 1.2, "request_rate_rps": 618, "gateway_retry_rate_rps": 6, "gc_pause_p99_ms": 124},
            {"t": "2026-04-19T11:45:00Z", "rss_bytes_p95": 449839104, "error_rate_pct": 1.3, "request_rate_rps": 620, "gateway_retry_rate_rps": 6, "gc_pause_p99_ms": 156},
            {"t": "2026-04-19T12:15:00Z", "rss_bytes_p95": 484442112, "error_rate_pct": 1.4, "request_rate_rps": 623, "gateway_retry_rate_rps": 6, "gc_pause_p99_ms": 184}
          ]
        }
        
      • traces.jsonl 933 B · in bundle
    • 07-blast-radius-asymmetric-revert
      • deploys.json 905 B
        {
          "service": "notifications-svc",
          "deploys": [
            {
              "service": "notifications-svc",
              "version": "v8.11.4",
              "commit": "3a1c059",
              "deployed_at": "2026-05-26T09:48:11Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Bump observability SDK minor version.",
              "bundle_size": 1
            },
            {
              "service": "notifications-svc",
              "version": "v8.12.0",
              "commit": "8b3f4d1",
              "deployed_at": "2026-06-02T09:48:23Z",
              "deployed_by": "ci-cd",
              "diff_summary": "Weekly release train: (1) Twilio SMS API v2 migration in internal/sms/twilio.go, (2) email template refactor, (3) push notification batching optimization, (4) removal of deprecated SES region fallback, (5) new /healthz endpoint, (6) express 4.x -> 5.x upgrade.",
              "bundle_size": 6
            }
          ],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.7 KB · in bundle
      • metrics.json 1.4 KB
        {
          "service": "notifications-svc",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-06-02T09:48:00Z", "rss_bytes_p95": 215809024, "error_rate_pct": 0.4, "request_rate_rps": 142, "gateway_retry_rate_rps": 2, "sms_error_rate_pct": 0.2, "email_error_rate_pct": 0.3, "push_error_rate_pct": 0.4},
            {"t": "2026-06-02T09:51:00Z", "rss_bytes_p95": 216333312, "error_rate_pct": 12.4, "request_rate_rps": 144, "gateway_retry_rate_rps": 3, "sms_error_rate_pct": 100.0, "email_error_rate_pct": 0.3, "push_error_rate_pct": 0.4},
            {"t": "2026-06-02T09:55:00Z", "rss_bytes_p95": 216595456, "error_rate_pct": 12.6, "request_rate_rps": 146, "gateway_retry_rate_rps": 3, "sms_error_rate_pct": 100.0, "email_error_rate_pct": 0.4, "push_error_rate_pct": 0.4},
            {"t": "2026-06-02T10:00:00Z", "rss_bytes_p95": 216857600, "error_rate_pct": 12.8, "request_rate_rps": 148, "gateway_retry_rate_rps": 3, "sms_error_rate_pct": 100.0, "email_error_rate_pct": 0.3, "push_error_rate_pct": 0.5},
            {"t": "2026-06-02T10:03:00Z", "rss_bytes_p95": 217119744, "error_rate_pct": 12.9, "request_rate_rps": 149, "gateway_retry_rate_rps": 3, "sms_error_rate_pct": 100.0, "email_error_rate_pct": 0.4, "push_error_rate_pct": 0.4},
            {"t": "2026-06-02T10:10:00Z", "rss_bytes_p95": 217381888, "error_rate_pct": 13.1, "request_rate_rps": 151, "gateway_retry_rate_rps": 3, "sms_error_rate_pct": 100.0, "email_error_rate_pct": 0.3, "push_error_rate_pct": 0.4}
          ]
        }
        
      • traces.jsonl 1.5 KB · in bundle
    • 08-deploy-correlator-confirmation-bias
      • deploys.json 812 B
        {
          "service": "users-api",
          "deploys": [
            {
              "service": "users-api",
              "version": "v9.1.0",
              "commit": "4e2c891",
              "deployed_at": "2026-07-11T14:22:14Z",
              "deployed_by": "ci-cd",
              "diff_summary": "internal/profile/avatar.go: switch avatar CDN URL format on /profile page."
            }
          ],
          "infra_changes": [],
          "rbac_changes": [
            {
              "resource": "rolebinding/users-api-secrets-reader",
              "kind": "RoleBinding",
              "namespace": "users",
              "changed_at": "2026-07-11T14:25:42Z",
              "changed_by": "platform-team",
              "diff_summary": "Deleted the RoleBinding granting the users-api ServiceAccount 'get' on Secrets in namespace 'users'. Audit log note: 'intended for sibling cluster' (operator error)."
            }
          ],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 2 KB · in bundle
      • metrics.json 1.3 KB
        {
          "service": "users-api",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-07-11T14:18:00Z", "rss_bytes_p95": 161480704, "error_rate_pct": 0.2, "request_rate_rps": 215, "gateway_retry_rate_rps": 2, "auth_failure_count_rps": 0.0},
            {"t": "2026-07-11T14:22:00Z", "rss_bytes_p95": 161742848, "error_rate_pct": 0.3, "request_rate_rps": 217, "gateway_retry_rate_rps": 2, "auth_failure_count_rps": 0.0},
            {"t": "2026-07-11T14:26:00Z", "rss_bytes_p95": 162004992, "error_rate_pct": 0.6, "request_rate_rps": 218, "gateway_retry_rate_rps": 2, "auth_failure_count_rps": 1.4},
            {"t": "2026-07-11T14:29:00Z", "rss_bytes_p95": 162267136, "error_rate_pct": 1.7, "request_rate_rps": 220, "gateway_retry_rate_rps": 3, "auth_failure_count_rps": 4.1},
            {"t": "2026-07-11T14:33:00Z", "rss_bytes_p95": 162529280, "error_rate_pct": 2.4, "request_rate_rps": 221, "gateway_retry_rate_rps": 3, "auth_failure_count_rps": 6.2},
            {"t": "2026-07-11T14:36:00Z", "rss_bytes_p95": 162791424, "error_rate_pct": 3.1, "request_rate_rps": 223, "gateway_retry_rate_rps": 3, "auth_failure_count_rps": 7.4},
            {"t": "2026-07-11T14:38:00Z", "rss_bytes_p95": 163053568, "error_rate_pct": 3.4, "request_rate_rps": 224, "gateway_retry_rate_rps": 3, "auth_failure_count_rps": 7.8}
          ]
        }
        
      • traces.jsonl 1.3 KB · in bundle
    • 09-zero-changes-external-cert-expiry
      • deploys.json 145 B
        {
          "service": "webhook-receiver",
          "deploys": [],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.2 KB · in bundle
      • metrics.json 1.3 KB
        {
          "service": "webhook-receiver",
          "memory_limit_bytes": 268435456,
          "samples": [
            {"t": "2026-08-20T03:03:00Z", "rss_bytes_p95": 89653248, "error_rate_pct": 0.2, "request_rate_rps": 78, "gateway_retry_rate_rps": 1, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-08-20T03:06:00Z", "rss_bytes_p95": 89915392, "error_rate_pct": 1.4, "request_rate_rps": 79, "gateway_retry_rate_rps": 1, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-08-20T03:10:00Z", "rss_bytes_p95": 90177536, "error_rate_pct": 3.1, "request_rate_rps": 80, "gateway_retry_rate_rps": 2, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-08-20T03:15:00Z", "rss_bytes_p95": 90439680, "error_rate_pct": 4.8, "request_rate_rps": 81, "gateway_retry_rate_rps": 2, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-08-20T03:18:00Z", "rss_bytes_p95": 90701824, "error_rate_pct": 5.6, "request_rate_rps": 82, "gateway_retry_rate_rps": 2, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-08-20T03:22:00Z", "rss_bytes_p95": 90963968, "error_rate_pct": 6.4, "request_rate_rps": 83, "gateway_retry_rate_rps": 2, "dns_resolver_errors_rps": 0.0},
            {"t": "2026-08-20T03:25:00Z", "rss_bytes_p95": 91226112, "error_rate_pct": 6.8, "request_rate_rps": 84, "gateway_retry_rate_rps": 2, "dns_resolver_errors_rps": 0.0}
          ]
        }
        
      • traces.jsonl 1.4 KB · in bundle
    • 10-multi-region-asymmetry
      • deploys.json 138 B
        {
          "service": "image-svc",
          "deploys": [],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 1.7 KB · in bundle
      • metrics.json 2 KB
        {
          "service": "image-svc",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-09-14T11:27:00Z", "region": "us-east-1", "rss_bytes_p95": 192937984, "error_rate_pct": 0.4, "request_rate_rps": 318, "gateway_retry_rate_rps": 4},
            {"t": "2026-09-14T11:27:00Z", "region": "us-west-2", "rss_bytes_p95": 193462272, "error_rate_pct": 0.3, "request_rate_rps": 214, "gateway_retry_rate_rps": 2},
            {"t": "2026-09-14T11:32:00Z", "region": "us-east-1", "rss_bytes_p95": 193200128, "error_rate_pct": 7.4, "request_rate_rps": 319, "gateway_retry_rate_rps": 4},
            {"t": "2026-09-14T11:32:00Z", "region": "us-west-2", "rss_bytes_p95": 193724416, "error_rate_pct": 0.3, "request_rate_rps": 215, "gateway_retry_rate_rps": 2},
            {"t": "2026-09-14T11:38:00Z", "region": "us-east-1", "rss_bytes_p95": 193462272, "error_rate_pct": 18.2, "request_rate_rps": 321, "gateway_retry_rate_rps": 5},
            {"t": "2026-09-14T11:38:00Z", "region": "us-west-2", "rss_bytes_p95": 193986560, "error_rate_pct": 0.4, "request_rate_rps": 216, "gateway_retry_rate_rps": 2},
            {"t": "2026-09-14T11:42:00Z", "region": "us-east-1", "rss_bytes_p95": 193724416, "error_rate_pct": 22.8, "request_rate_rps": 322, "gateway_retry_rate_rps": 5},
            {"t": "2026-09-14T11:42:00Z", "region": "us-west-2", "rss_bytes_p95": 194248704, "error_rate_pct": 0.3, "request_rate_rps": 217, "gateway_retry_rate_rps": 2},
            {"t": "2026-09-14T11:46:00Z", "region": "us-east-1", "rss_bytes_p95": 193986560, "error_rate_pct": 24.4, "request_rate_rps": 324, "gateway_retry_rate_rps": 6},
            {"t": "2026-09-14T11:46:00Z", "region": "us-west-2", "rss_bytes_p95": 194510848, "error_rate_pct": 0.4, "request_rate_rps": 218, "gateway_retry_rate_rps": 2},
            {"t": "2026-09-14T11:50:00Z", "region": "us-east-1", "rss_bytes_p95": 194248704, "error_rate_pct": 25.1, "request_rate_rps": 326, "gateway_retry_rate_rps": 6},
            {"t": "2026-09-14T11:50:00Z", "region": "us-west-2", "rss_bytes_p95": 194772992, "error_rate_pct": 0.3, "request_rate_rps": 219, "gateway_retry_rate_rps": 2}
          ]
        }
        
      • traces.jsonl 1.6 KB · in bundle
    • 11-capacity-bound-organic-growth
      • deploys.json 139 B
        {
          "service": "search-svc",
          "deploys": [],
          "infra_changes": [],
          "rbac_changes": [],
          "feature_flags": [],
          "scheduled_jobs": []
        }
        
      • logs.jsonl 948 B · in bundle
      • metrics.json 1.2 KB
        {
          "service": "search-svc",
          "memory_limit_bytes": 536870912,
          "samples": [
            {"t": "2026-10-08T10:40:00Z", "rss_bytes_p95": 304087040, "error_rate_pct": 0.3, "request_rate_rps": 412, "gateway_retry_rate_rps": 3, "queue_depth_p99": 12},
            {"t": "2026-10-08T10:45:00Z", "rss_bytes_p95": 304349184, "error_rate_pct": 0.4, "request_rate_rps": 521, "gateway_retry_rate_rps": 3, "queue_depth_p99": 38},
            {"t": "2026-10-08T10:48:00Z", "rss_bytes_p95": 304611328, "error_rate_pct": 0.9, "request_rate_rps": 614, "gateway_retry_rate_rps": 3, "queue_depth_p99": 72},
            {"t": "2026-10-08T10:51:00Z", "rss_bytes_p95": 304873472, "error_rate_pct": 1.8, "request_rate_rps": 658, "gateway_retry_rate_rps": 4, "queue_depth_p99": 118},
            {"t": "2026-10-08T10:55:00Z", "rss_bytes_p95": 305135616, "error_rate_pct": 3.4, "request_rate_rps": 692, "gateway_retry_rate_rps": 4, "queue_depth_p99": 154},
            {"t": "2026-10-08T11:00:00Z", "rss_bytes_p95": 305397760, "error_rate_pct": 4.1, "request_rate_rps": 704, "gateway_retry_rate_rps": 4, "queue_depth_p99": 172},
            {"t": "2026-10-08T11:05:00Z", "rss_bytes_p95": 305659904, "error_rate_pct": 4.6, "request_rate_rps": 718, "gateway_retry_rate_rps": 4, "queue_depth_p99": 184}
          ]
        }
        
      • traces.jsonl 1.2 KB · in bundle
  • tests
    • eval
      • eval_results.json 852.7 KB
        [
          {
            "incident": "01-oom-cascade",
            "condition": "control",
            "trial": 0,
            "score": 5,
            "scores_by_item": [
              1,
              1,
              1,
              1,
              0,
              1,
              0
            ],
            "notes": [
              "Agent identifies T0 as 2026-03-12T14:32:00Z, exactly matching the reference.",
              "Agent correctly identifies exactly 1 change in the window: deploy of payments-api v4.18.0 (commit 9f3a2c1).",
              "Agent explicitly classifies the failure as OOM (exit code 137, kernel OOM kills, memory limit breach).",
              "Agent cites signals from at least 4 distinct categories: metrics (RSS, error rate), logs (GC pause, OOM log), traces (retry trace, deadline exceeded), orchestrator/pod events (OOM-killed events), and change events (deploy record).",
              "Agent quantifies services affected and business impact (payment failures) but does not quantify the number of users affected (no user count or percentage of user base given), so all three required elements are not fully present.",
              "Agent's top recommended action (Step 1) is 'kubectl rollout undo deployment/payments-api', which rolls back to v4.17.4, matching the reference mitigation.",
              "Agent provides timeline, root-cause hypothesis, and mitigation, but does not include a dedicated 'open questions' section \u2014 there are no explicit unresolved questions listed for the next responder, so the handoff is missing one of the four required elements."
            ],
            "agent_output": "# Incident Investigation: payments-api Error Rate Spike\n## 2026-03-12T14:32:00Z\n\n---\n\n## 1. Timeline of Events\n\n| Time (UTC) | Event | Source |\n|---|---|---|\n| **14:17:00** | Baseline state: p95 RSS ~80MB, error rate 0.3%, request rate ~412 rps | metrics.json |\n| **14:18:14** | Deploy of **v4.18.0** begins (WebhookBuffer, ~210MB per-request memory) | deploys.json |\n| **14:18:30\u201335** | All three pods (x4k2, m2n8, b7q1) start on new version | pod_events.jsonl |\n| **14:20:00** | p95 RSS jumps to **214MB** (~2.7\u00d7 baseline) immediately post-deploy | metrics.json |\n| **14:25:00** | p95 RSS climbs to **380MB** (71% of 512Mi limit); error rate still nominal | metrics.json |\n| **14:29:00** | p95 RSS at **469MB** (87% of limit); error rate ticks to 0.6%; first GC pressure implied | metrics.json |\n| **14:30:00** | p95 RSS at **487MB** (91% of limit); error rate 0.8%; gateway retry rate begins rising | metrics.json |\n| **14:30:11** | GC pause warning on x4k2: 187ms pause, heap ~481MB | logs.jsonl |\n| **14:30:12** | **x4k2 OOM-killed** (heap_inuse 481MB, limit 512Mi) | logs.jsonl + pod_events.jsonl |\n| **14:30:14** | api-gateway receives 502 from payments-api; retries begin for req-c9d2e1 | logs.jsonl + traces.jsonl |\n| **14:30:48** | **m2n8 OOM-killed** | pod_events.jsonl |\n| **14:31:00** | Error rate spikes to **7.4%** \u2014 alert threshold crossed; request rate jumps to 469 rps; gateway retry rate 41 rps | metrics.json |\n| **14:31:22** | **b7q1 OOM-killed** \u2014 all three original pods now dead | pod_events.jsonl |\n| **14:32:00** | **Alert fires** (payments_api_error_rate > 5%); error rate 24.1%; request rate 612 rps; retry rate 184 rps | alert + metrics.json |\n| **14:32:01** | Gateway logs retry budget exhausted for req-c9d2e1 after 3 attempts | logs.jsonl |\n| **14:32:05** | **x4k2 OOM-killed again** (restarted pod immediately OOMs on new load) | pod_events.jsonl |\n| **14:33:00** | Error rate **38.2%**; request rate 743 rps (+80% over baseline); retry rate 312 rps | metrics.json |\n| **14:33:12** | Trace shows 1204ms deadline exceeded \u2014 pods drowning under retry storm | traces.jsonl |\n| **14:33:18** | r5j7 OOM-killed | pod_events.jsonl |\n| **14:34:00** | Error rate **46.8%**; 821 rps; retry rate 389 rps \u2014 **cascading failure in progress** | metrics.json |\n| **14:34:01** | c9w4 OOM-killed | pod_events.jsonl |\n| **14:36:00** | Investigation triggered | (given) |\n\n---\n\n## 2. Root-Cause Hypothesis\n\n### Primary Root Cause\n> **v4.18.0 introduced `WebhookBuffer`, which increased per-request heap allocation from ~80MB to ~210MB. With a pod memory limit of 512Mi, this reduced safe concurrency headroom from ~6 concurrent requests to ~2, causing pods to OOM-kill under normal load within ~12 minutes of deploy.**\n\n### Secondary Amplifier (Retry Storm)\n> **The api-gateway retry behavior converted a transient OOM event into a cascading failure. Each OOM-killed pod returns a 502, which the gateway retries \u2014 routing those retries to already-memory-pressured surviving pods, accelerating their OOM, which generates more 502s, which generates more retries. This is a classic retry amplification loop.**\n\n### Supporting Evidence\n\n#### Evidence for WebhookBuffer as root cause\n| Evidence | Detail |\n|---|---|\n| Deploy timing | v4.18.0 deployed at 14:18:14; p95 RSS jumped from 80MB \u2192 214MB by 14:20 (first sample post-deploy), a **2.7\u00d7 increase** perfectly matching the diff's stated ~80MB \u2192 ~210MB per-request footprint |\n| Diff statement | Explicitly states \"Per-request memory footprint increase from ~80MB to ~210MB\" |\n| No other changes | `infra_changes`, `rbac_changes`, `feature_flags`, `scheduled_jobs` all empty; only other deploy (v4.17.4, 2 days prior) is unrelated to memory |\n| OOM-kill specificity | All OOM events are exit code 137 (SIGKILL from kernel OOM), consistent with hard memory limit breach, not application-level errors |\n| Memory ceiling convergence | RSS at 14:31\u201314:34 plateaus near **511MB** \u2014 pods are constantly hitting the 512Mi ceiling, not growing past it, because they keep getting killed and restarting |\n\n#### Evidence for retry storm as amplifier\n| Evidence | Detail |\n|---|---|\n| Request rate inflation | Baseline ~412 rps \u2192 821 rps by 14:34 (+99%) with no real traffic growth; the delta is synthetic retry traffic |\n| Gateway retry rate | 4 rps at baseline \u2192 389 rps at 14:34 (**97\u00d7 increase**) |\n| Error rate progression | Non-linear error rate growth (0.8% \u2192 7.4% \u2192 24.1% \u2192 38.2% \u2192 46.8%) is consistent with feedback loop, not a simple overload |\n| Trace evidence | tr-retry-001 shows the gateway immediately routing retries back to a still-degraded pool; tr-storm-014 at 14:33 shows 1204ms latency with 2 retries \u2014 dying pods accepting connections but timing out under heap pressure |\n| Retry budget interaction | v4.17.4 *increased* ledger-svc retry budget from 2\u21923; the gateway also retries. This means a single request can now fan out to 3+ attempts, each carrying ~210MB webhook buffer allocation |\n\n---\n\n## 3. Blast Radius Estimate\n\n### Direct Impact\n- **Service**: `payments-api` \u2014 completely degraded, error rate ~47% and climbing at time of investigation\n- **All pods affected**: 100% of the pod pool is OOM-kill cycling (x4k2, m2n8, b7q1 confirmed; r5j7, c9w4 also killed)\n- **Users/clients**: Any client routing through `api-gateway` to `payments-api` is experiencing ~47% request failure\n\n### Downstream Impact\n| Service | Impact | Mechanism |\n|---|---|---|\n| `api-gateway` | Elevated error responses to end users; retry loop consuming gateway worker threads | 502s from payments-api + retry storm |\n| `ledger-svc` | Likely **under-loaded** (payments-api is dying before reaching it \u2014 traces show no ledger spans after 14:30) | payments-api OOMs before completing requests |\n| End-user clients | Payment failures; depending on client retry behavior, some clients may be generating additional retry amplification at the edge | 502/504 responses |\n\n### What is NOT impacted (so far)\n- `ledger-svc` itself appears healthy (no ledger errors in any telemetry)\n- The memory issue is contained to `payments-api` pods \u2014 no node-level OOM evidence\n- No data corruption risk identified (WebhookBuffer is a buffering layer, not a write path change per the diff)\n\n### Risk if not mitigated immediately\n- Error rate is on a trajectory toward **~60-70%** within the next few minutes\n- Restarting pods under current config is futile \u2014 they will OOM-kill within seconds of receiving load (as demonstrated by x4k2 at 14:32:05 \u2014 restarted and killed again in <3 minutes)\n- The retry storm is **self-reinforcing**; without intervention it will fully saturate the pod pool\n\n---\n\n## 4. Recommended Mitigation\n\n### **Step 1 \u2014 Do This First (Right Now): Roll Back v4.18.0**\n```bash\n# Immediately roll back to v4.17.4\nkubectl rollout undo deployment/payments-api\n# Verify rollout status\nkubectl rollout status deployment/payments-api --timeout=120s\n# Watch pod restarts stabilize\nkubectl get pods -l app=payments-api -w\n```\n**Rationale**: This is the single change that will stop the bleeding. Rolling back eliminates the WebhookBuffer and returns per-request memory to ~80MB, which fits comfortably within 512Mi limits. Everything else (retry storm, OOM cycling) is a consequence of this one deploy \u2014 removing it collapses the failure mode.\n\n**Expected result**: Within 60\u201390 seconds of rollout completing, new pods should stabilize at ~80MB RSS, stop OOM-killing, and error rate should fall back toward baseline 0.3%.\n\n---\n\n### **Step 2 \u2014 Simultaneously: Break the Retry Storm**\nWhile the rollback propagates, the retry amplification is still generating artificial load that will stress even healthy pods during the transition window.\n\n```bash\n# Option A: Temporarily reduce gateway retry count to 0 for payments-api\n# (exact mechanism depends on your gateway config \u2014 e.g., Envoy, nginx, etc.)\n# Example for Envoy via xDS/config patch:\nkubectl patch configmap api-gateway-config --patch '\n  {\"data\": {\"payments_api_retry_policy\": \"max_retries: 0\"}}'\n\n# Option B: If you have circuit breaker capability, open it now\n# This fails fast instead of retrying, stopping the amplification loop\n```\n**Rationale**: Without this, even healthy pods from the rollback will receive the artificially inflated ~800+ rps load during the transition and may struggle to drain the retry backlog.\n\n---\n\n### **Step 3 \u2014 Post-Stabilization: Verify Recovery**\n```bash\n# Confirm RSS has returned to baseline\nkubectl top pods -l app=payments-api\n\n# Check error rate (should be < 1% within 2-3 minutes of rollback completing)\n# Query your metrics system for payments_api_error_rate\n\n# Confirm no pods are in OOMKilled/CrashLoopBackOff\nkubectl get pods -l app=payments-api -o wide\n```\n\n---\n\n### **Step 4 \u2014 Before Re-deploying v4.18.0: Required Fixes**\n\nThe feature itself may be legitimate, but it **cannot ship** without the following:\n\n| Fix | Action |\n|---|---|\n| **Memory limit increase** | If 210MB/request is the new baseline, the pod limit must be raised. At 412 rps with typical concurrency (say 50 in-flight), you need `50 \u00d7 210MB = 10.5GB` across the pool, or scale pods accordingly. Recalculate and set `resources.limits.memory` accordingly. |\n| **WebhookBuffer cap** | The buffer should have a maximum size and/or be backed by a bounded pool, not unbounded heap allocation. This is a design issue in `internal/webhook/buffer.go`. |\n| **Soak testing requirement** | A change this large in memory footprint should have been caught by a load test or canary deploy. Add memory regression gates to the CI pipeline (e.g., fail if p95 RSS in staging exceeds 150% of previous baseline). |\n| **Canary/progressive rollout** | Deploy to 10% of pods first, observe RSS for 10+ minutes before full rollout. |\n| **Gateway retry circuit breaker** | The retry storm revealed that gateway retries + OOM-killed pods create a dangerous feedback loop. Implement exponential backoff, jitter, and a concurrency limit on retries to payments-api specifically. |\n| **Ledger retry budget review** | v4.17.4 increased ledger-svc retries to 3. Combined with gateway-level retries, a single user request can now generate 3\u20139 backend attempts. Audit the full retry chain and set an explicit max-amplification budget. |\n\n---\n\n## 5. Summary\n\n```\nROOT CAUSE:  v4.18.0 deploy (14:18:14) introduced WebhookBuffer, 2.7\u00d7 per-request\n             memory increase. Pod limit of 512Mi became insufficient, causing\n             systematic OOM-kills starting at 14:30:12.\n\nAMPLIFIER:   api-gateway retry behavior routed 502s back to OOM-pressured pods,\n             creating a self-reinforcing loop that inflated request rate 2\u00d7 and\n             drove error rate to ~47% in 4 minutes.\n\nIMMEDIATE:   kubectl rollout undo deployment/payments-api\n             + suppress gateway retries during transition window\n\nSTATUS:      Active cascading failure. Every minute without rollback = more\n             OOM-kill cycles, higher retry amplification, worse user impact.\n```",
            "agent_chars": 10956,
            "agent_s": 68.3,
            "elapsed_s": 78.1
          },
          {
            "incident": "01-oom-cascade",
            "condition": "control",
            "trial": 1,
            "score": 6,
            "scores_by_item": [
              1,
              1,
              1,
              1,
              1,
              1,
              0
            ],
            "notes": [
              "Agent identifies T0 as 14:32:00 UTC when the alert fires, exactly matching the reference.",
              "Agent correctly identifies exactly 1 change in the window: deploy payments-api v4.18.0 (commit 9f3a2c1).",
              "Agent explicitly classifies the failure as OOM (OOMKill cascade) throughout the investigation.",
              "Agent cites signals from at least 4 distinct categories: metrics (RSS p95, error rate, request rate), logs (runtime: out of memory, GC pause warning, retry budget exhausted), orchestrator events (OOMKilled pod terminations), and change events (v4.18.0 deploy with commit 9f3a2c1).",
              "Agent quantifies users affected (~47% of payment API calls failing), surfaces affected (payments-api, api-gateway, webhook delivery, end users), and business impact (revenue impact, potential double-charge/no-charge risk for in-flight transactions).",
              "Agent's top recommended action is explicitly 'Roll back to v4.17.4 immediately' with kubectl commands, matching the reference mitigation.",
              "Agent provides timeline, ranked hypotheses, and recommended mitigations but does not include a dedicated 'open questions' section \u2014 missing one of the four required elements for a structured handoff."
            ],
            "agent_output": "# Incident Investigation: payments-api Error Rate Spike\n\n## Executive Summary\n\nA memory regression introduced in `v4.18.0` caused all payments-api pods to OOMKill in a cascade. Gateway retries amplified the load on already-exhausted pods, creating a positive feedback loop that drove error rates from <1% to ~47% in under 5 minutes.\n\n---\n\n## Timeline\n\n| Time (UTC) | Event |\n|---|---|\n| **14:17:00** | Baseline: RSS p95 ~80MB, error rate 0.3%, request rate ~412 rps \u2014 healthy |\n| **14:18:14** | `v4.18.0` deployed; `WebhookBuffer` introduced, per-request memory footprint ~80MB \u2192 ~210MB |\n| **14:18:30\u201335** | Three new pods (`x4k2`, `m2n8`, `b7q1`) start on `v4.18.0` |\n| **14:20:00** | RSS p95 jumps to ~214MB (2.7\u00d7 baseline), confirming memory regression is live |\n| **14:25:00** | RSS p95 at ~380MB (~73% of 512Mi limit); traffic still normal; no errors yet |\n| **14:29:00** | RSS p95 at ~469MB (~87% of limit); GC pressure building; error rate still low (0.6%) |\n| **14:30:11** | GC pause warning on `x4k2` (187ms) \u2014 heap under severe pressure |\n| **14:30:12** | `x4k2` logs `runtime: out of memory` (heap_inuse ~481MB), then OOMKilled |\n| **14:30:14** | API gateway gets 502 from payments-api; begins retry for `req-c9d2e1` |\n| **14:30:48** | `m2n8` OOMKilled |\n| **14:31:22** | `b7q1` OOMKilled \u2014 **all original pods now dead or restarting** |\n| **14:31:00** | Error rate spikes to 7.4%; gateway retries jump from ~5 \u2192 41 rps; request rate inflates to 469 rps |\n| **14:32:00** | Alert fires (`error_rate > 5%`); `x4k2` OOMKills again on restart; error rate 24.1%; retries 184 rps; request rate 612 rps |\n| **14:32:01** | Gateway logs retry budget exhausted for `req-c9d2e1` after 3 attempts |\n| **14:33:00** | `r5j7` OOMKilled; error rate 38.2%; retries 312 rps; request rate 743 rps |\n| **14:34:00** | `c9w4` OOMKilled; error rate 46.8%; retries 389 rps; request rate 821 rps |\n| **14:36:00** | Investigation triggered |\n\n**Key observation:** Between 14:31 and 14:34, real request rate grew ~400\u2192821 rps (+105%), almost entirely explained by retry amplification (389 rps of retries at 14:34). This is a textbook retry storm.\n\n---\n\n## Root Cause Hypothesis\n\n### Primary Cause: Memory regression in `v4.18.0` \u2014 `WebhookBuffer` holds full payload bodies in memory\n\n**Commit `9f3a2c1`** introduced `internal/webhook/buffer.go`, which buffers complete webhook payloads in memory before batch-writing. The diff explicitly states per-request memory footprint increased from ~80MB to ~210MB \u2014 a **2.6\u00d7 increase**.\n\n**Supporting evidence:**\n\n| Data Point | Value | Interpretation |\n|---|---|---|\n| RSS p95 pre-deploy (14:17) | 80MB | Matches documented ~80MB baseline |\n| RSS p95 post-deploy (14:20) | 214MB | Matches documented ~210MB new footprint exactly |\n| Memory limit | 512MB | Only allows ~2.4 concurrent webhook-buffering requests per pod |\n| Time from deploy to first OOM | ~12 minutes | Consistent with gradual heap accumulation under steady ~415 rps traffic |\n| All three pods OOM within 70 seconds | 14:30:12\u201314:31:22 | All deployed simultaneously, all hit limit at same time |\n\nThe buffering design creates unbounded in-memory accumulation under even moderate concurrency. At ~415 rps with ~210MB/request held in the buffer pending batch-write, a pod with a 512MB limit can only tolerate a very small number of in-flight webhook operations simultaneously before the heap is exhausted.\n\n### Secondary Cause: Retry storm amplifying load on OOM-cycling pods\n\nOnce pods began OOMKilling and restarting (which takes several seconds), the API gateway's retry logic sent failed requests to the remaining pods \u2014 which were themselves near their memory ceiling. Each retry added ~210MB of buffer memory to an already-saturated pool.\n\n**Supporting evidence:**\n\n```\n14:31:00  retries:  41 rps  \u2192  request_rate: 469 rps  (error_rate: 7.4%)\n14:32:00  retries: 184 rps  \u2192  request_rate: 612 rps  (error_rate: 24.1%)\n14:33:00  retries: 312 rps  \u2192  request_rate: 743 rps  (error_rate: 38.2%)\n14:34:00  retries: 389 rps  \u2192  request_rate: 821 rps  (error_rate: 46.8%)\n```\n\nThe retry rate grows super-linearly because each failed retry attempt (from a pod that OOMKills mid-request) itself triggers further retries. The situation is self-reinforcing: retries \u2192 more memory pressure \u2192 more OOMKills \u2192 more retries.\n\nNote: The prior deploy (`v4.17.4`) increased the ledger-svc retry budget from 2 to 3, but traces show ledger-svc latency was normal throughout (22\u201324ms baseline) and no ledger errors appear. The gateway retry budget (3 attempts, visible in logs) is the active amplifier \u2014 this is a separate retry path.\n\n### Contributing Factor: No memory-based autoscaling or pre-OOM circuit breaker\n\nMemory climbed from 80MB \u2192 ~510MB over ~12 minutes with no automated response. A memory utilization alert or HPA memory policy would have caught this before pods started dying.\n\n---\n\n## Blast Radius\n\n### Directly affected\n- **payments-api**: All pods cycling in OOMKill loop; effectively serving ~50% error rate at time of investigation\n- **Webhook delivery**: All webhook payloads in-flight when pods OOMKill are lost (not written); magnitude depends on webhook volume but every OOMKill drops buffered payloads with no acknowledgment\n\n### Downstream affected\n- **API gateway**: Saturated retry budget, degraded for all clients calling payments-api (not just webhook paths)\n- **End users / callers**: ~47% of payment API calls failing by 14:34; `req-c9d2e1` trace shows user-visible `retry_budget_exhausted` errors\n- **Potential data integrity risk**: Payments that reached payments-api but were processing when OOMKill occurred may be in an ambiguous state \u2014 the caller gets a 502 but the payment operation may have partially executed\n\n### Not affected (based on available evidence)\n- **ledger-svc**: Latency and behavior normal throughout traces\n- **Other services**: No evidence of impact outside payments-api and api-gateway\n\n### Severity assessment\n**SEV-1 / Critical** \u2014 ~50% error rate on a payments API represents significant revenue impact and potential double-charge/no-charge risk for in-flight transactions at OOMKill moments.\n\n---\n\n## Recommended Mitigations\n\n### What I would do FIRST (next 5 minutes)\n\n**Roll back to `v4.17.4` immediately.**\n\n```bash\nkubectl set image deployment/payments-api \\\n  payments-api=payments-api:v4.17.4\n\n# Verify rollout\nkubectl rollout status deployment/payments-api\nkubectl rollout history deployment/payments-api\n```\n\nThis is the single highest-leverage action. It removes the memory regression and, once healthy pods are running, the retry storm will self-extinguish because requests will succeed rather than triggering further retries.\n\n**Simultaneously \u2014 reduce gateway retry amplification while rollout is in progress:**\n\nIf the rollout takes >60 seconds and pods continue to OOMKill, temporarily lower or disable gateway retries to payments-api to stop the feedback loop:\n\n```yaml\n# Envoy/Istio example \u2014 set retry attempts to 0 temporarily\nretryPolicy:\n  retryOn: \"5xx\"\n  numRetries: 0\n```\n\nRestore after rollout confirms healthy.\n\n---\n\n### Short-term (next 24 hours)\n\n**1. Audit in-flight transactions at OOMKill boundaries**\n\nQuery the payments datastore for transactions with `status = processing` and `updated_at` between 14:30:12 and 14:34:01 (the OOMKill window). These are at risk of being in an ambiguous state. Reconcile against ledger-svc records.\n\n**2. Recover lost webhook payloads**\n\nIdentify all webhook events that were buffered in-memory at time of each OOMKill. Cross-reference with the upstream event source to re-enqueue and re-deliver. Until `v4.18.0` is fixed, confirm whether the batch-write even committed before OOMKill.\n\n**3. Add a memory utilization alert**\n\n```yaml\nalert: PaymentsAPIMemoryPressure\nexpr: |\n  container_memory_rss{container=\"payments-api\"} \n  / container_spec_memory_limit_bytes{container=\"payments-api\"} > 0.80\nfor: 2m\nseverity: warning\n```\n\nMemory crossed 80% of limit at ~14:28 \u2014 a 2-minute alert there would have caught this ~4 minutes before the first OOMKill.\n\n**4. Add OOMKill alerting**\n\n```yaml\nalert: PodOOMKilled\nexpr: kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\"} == 1\nfor: 0m\nseverity: critical\n```\n\n---\n\n### Medium-term (before `v4.18.0` is re-deployed)\n\n**5. Fix the WebhookBuffer design**\n\nThe core design is unsafe for a memory-limited container. Required changes before re-deployment:\n\n- **Stream to disk or durable queue** instead of holding full bodies in memory (e.g., write to a temp file or Kafka topic, then batch-commit file paths/offsets)\n- **Bound the in-memory buffer** with a hard cap (e.g., reject or back-pressure at 50MB of buffered webhook data per pod)\n- **Add per-request memory instrumentation** so heap growth from webhook buffering is visible in metrics\n\n**6. Add memory-based HPA or VPA**\n\nThe deployment had no automated scale-out response to memory pressure. At 80% memory utilization, scale out would have bought time:\n\n```yaml\n- type: Resource\n  resource:\n    name: memory\n    target:\n      type: Utilization\n      averageUtilization: 70\n```\n\n**7. Raise the memory limit as a temporary band-aid only**\n\n512MB is too tight for ~210MB/request footprint. If `v4.18.0` must be re-deployed before the design fix is done, raise the limit to 1.5\u20132Gi. But this is not a fix \u2014 it just delays the OOM under higher concurrency.\n\n**8. Add a pre-deploy memory regression check to CI**\n\nProfile memory usage in a load test environment as part of the deployment pipeline. A 2.6\u00d7 memory increase should fail a gate:\n\n```\nPASS: rss_p95 < 1.5\u00d7 baseline\nFAIL: rss_p95 = 2.6\u00d7 baseline (210MB vs 80MB) \u2192 block deploy\n```\n\n**9. Review gateway retry policy for non-idempotent endpoints**\n\nThe payments API handles financial transactions. Retrying a failed payment request without idempotency keys risks duplicate charges. Confirm all payment endpoints require idempotency keys, and consider configuring the gateway to only retry explicitly idempotent routes.\n\n---\n\n## Summary\n\n| | |\n|---|---|\n| **Root cause** | `v4.18.0` `WebhookBuffer` increased per-request memory 2.6\u00d7, exhausting 512Mi pod limit under normal traffic |\n| **Amplifier** | API gateway retry storm tripled effective request rate, accelerating OOMKill cycle |\n| **Detection gap** | No memory utilization alert; OOMKills not alerted; alert only fired on error rate (4 min after first OOMKill) |\n| **Immediate action** | Roll back to `v4.17.4` |\n| **Data risk** | In-flight transactions and buffered webhooks at OOMKill moments require reconciliation |",
            "agent_chars": 10547,
            "agent_s": 66.6,
            "elapsed_s": 75.0
          },
          {
            "incident": "01-oom-cascade",
            "condition": "control",
            "trial": 2,
            "score": 6,
            "scores_by_item": [
              1,
              1,
              1,
              1,
              0,
              1,
              1
            ],
            "notes": [
              "Agent identifies T0 as 14:32:00Z when the alert fires, exactly matching the reference.",
              "Agent correctly identifies exactly 1 change in the window: deploy of payments-api v4.18.0 (commit 9f3a2c1), with v4.17.4 noted as a prior change outside the relevant window.",
              "Agent explicitly classifies the failure as OOM, supported by detailed evidence of OOMKill events.",
              "Agent cites signals from at least 4 distinct categories: metrics (RSS p95, error rate, RPS, retry rate), logs (runtime: out of memory, heap_inuse), orchestrator events (OOMKilled pod terminations), traces (absent ledger-svc spans), and change events (v4.18.0 deploy).",
              "Agent surfaces affected services and business impact (data loss, webhook delivery failure) but does not quantify users affected (e.g., number of users, percentage of customers, or request volume impact as a proxy for user count).",
              "Agent's top recommended immediate action is to roll back v4.18.0 to v4.17.4, matching the reference mitigation exactly.",
              "Agent provides a structured handoff with a detailed timeline, ranked hypotheses (primary WebhookBuffer OOM + secondary retry storm), recommended mitigations in priority order, and open questions implicit in the data loss and ledger-svc scaling concerns."
            ],
            "agent_output": "# Incident Investigation: payments-api \u2014 2026-03-12\n\n---\n\n## 1. Timeline of Events\n\n| Time (UTC) | Event |\n|---|---|\n| **2026-03-10T09:14Z** | `v4.17.4` deployed \u2014 ledger-svc retry budget increased from 2\u21923 (low risk, no issues observed) |\n| **2026-03-12T14:18:14Z** | `v4.18.0` deployed \u2014 introduces `WebhookBuffer`, per-request memory footprint jumps ~80 MB \u2192 ~210 MB |\n| **14:18:30\u201314:18:35Z** | All three new pods (`x4k2`, `m2n8`, `b7q1`) start successfully under `v4.18.0` |\n| **14:17:00Z** *(pre-deploy baseline)* | RSS p95 = **80 MB**, error rate = 0.3%, RPS = 412, gateway retries = 4 rps |\n| **14:20:00Z** | RSS p95 jumps to **214 MB** immediately after deploy \u2014 consistent with new ~210 MB/request footprint |\n| **14:25:00Z** | RSS p95 = **380 MB** (~71% of 512 MB limit), error rate still nominal (0.4%) |\n| **14:29:00Z** | RSS p95 = **469 MB** (~87% of limit), error rate ticks up slightly (0.6%), GC pressure building |\n| **14:30:11Z** | GC pause warning on `x4k2` \u2014 187 ms pause, heap near exhaustion |\n| **14:30:12Z** | `x4k2` logs `runtime: out of memory` (heap_inuse = **481 MB**), then OOMKilled at **14:30:12Z** |\n| **14:30:14Z** | API gateway receives 502 from dying `x4k2`, begins retry (`req-c9d2e1`) |\n| **14:30:48Z** | `m2n8` OOMKilled \u2014 second pod lost |\n| **14:31:22Z** | `b7q1` OOMKilled \u2014 third pod lost; only restarted/new pods remain |\n| **14:31:00Z** | Error rate spikes to **7.4%**, request rate climbs to 469 rps, gateway retries hit **41 rps** \u2014 retry amplification begins |\n| **14:32:00Z** | Alert fires (`error_rate > 5%`). Error rate = **24.1%**, RPS = 612, gateway retries = **184 rps** |\n| **14:32:01Z** | Gateway exhausts retry budget for `req-c9d2e1` after 3 attempts |\n| **14:32:05Z** | `x4k2` (restarted) OOMKilled again \u2014 pods are restarting directly into OOM death, unable to stabilize |\n| **14:33:00Z** | Error rate = **38.2%**, RPS = **743**, retries = **312 rps** \u2014 full retry storm in progress |\n| **14:33:18Z** | New pod `r5j7` OOMKilled |\n| **14:34:00Z** | Error rate = **46.8%**, RPS = **821**, retries = **389 rps** \u2014 cascading failure |\n| **14:36:00Z** | Investigation triggered |\n\n---\n\n## 2. Root Cause Hypothesis\n\n### Primary Cause: Memory regression in `v4.18.0` (`WebhookBuffer`)\n\nThe `WebhookBuffer` introduced in `v4.18.0` holds **full payload bodies in memory** before batch-writing, inflating per-request memory from ~80 MB to ~210 MB. With a pod memory limit of 512 MB and ~420 RPS per pod at the time, the working set exhausted available memory within ~12 minutes of deployment.\n\n### Secondary Cause: Retry storm amplifying the blast radius\n\nOnce pods began OOMKilling, the API gateway's retry behavior (budget: 3 attempts, raised from 2 in `v4.17.4`) turned each failed request into up to 3 additional requests against the already-degraded pool. This created a **positive feedback loop**:\n\n```\nOOM kills reduce capacity\n  \u2192 more requests fail\n    \u2192 gateway retries amplify RPS (412 \u2192 821, ~2\u00d7)\n      \u2192 surviving/restarted pods hit OOM faster\n        \u2192 more OOM kills\n```\n\n**The retry budget increase in `v4.17.4` is a contributing factor** \u2014 it extended the amplification window from 2\u00d7 to 3\u00d7 retries, though it would not have caused this incident alone.\n\n### Supporting Evidence\n\n| Evidence | Supports |\n|---|---|\n| RSS p95 jumps from 80 MB \u2192 214 MB immediately at 14:20 (2 min post-deploy) | WebhookBuffer is the direct memory cause |\n| Memory growth is monotonic and correlated with RPS, not a leak pattern | Unbounded buffer accumulation per request, not a GC leak |\n| All three original pods OOMKill within a 70-second window (14:30:12\u201314:31:22) | Simultaneous memory pressure across all pods \u2014 not a single pod fault |\n| Restarted pods (`x4k2` at 14:32:05, `r5j7` at 14:33:18) OOMKill within ~60\u201390 seconds of starting | No \"safe\" restart state exists; pods immediately re-enter high-traffic and OOM again |\n| Gateway retry rate: 4 rps (baseline) \u2192 389 rps at 14:34 (~97\u00d7 amplification) | Retry storm is not organic traffic \u2014 it is failure-amplified |\n| `heap_inuse_bytes` in OOM logs: 481\u2013484 MB across all pods | Consistently hitting the same ceiling, confirming memory limit is the hard boundary |\n| `ledger-svc` spans are absent in all post-14:30 traces | Payments-api is dying before it can upstream to ledger-svc \u2014 confirms OOM is the failure mode, not ledger-svc |\n\n---\n\n## 3. Blast Radius Estimate\n\n### Directly impacted\n- **payments-api**: Functionally unavailable. Error rate at ~47% and rising, pods unable to sustain load. Restarted pods OOMKill before warming up.\n- **API gateway**: Retry storm is consuming gateway retry thread pool \u2014 `gateway_retry_rate_rps` at 389 vs normal 4. Gateway itself may be at risk of resource exhaustion if retry concurrency is unbounded.\n\n### Downstream risk (high confidence)\n- **Webhook consumers**: `WebhookBuffer` implies webhook writes are batched and held in memory. Any webhook delivery SLA is broken \u2014 buffered payloads in killed pods are **lost** (no persistent queue evident in the diff).\n- **Ledger-svc**: Not currently showing errors (absent from post-failure traces because payments-api dies first), but it is being starved of traffic. If it has its own health-check-based autoscaling, it may be scaling down, creating a second failure mode on recovery.\n\n### Indirect risk (medium confidence)\n- **Other services sharing the API gateway**: If the gateway is a shared component, retry storm threads could cause latency or queue buildup for unrelated services.\n- **Upstream clients**: Clients receiving 502s with retry logic of their own will further amplify traffic beyond the 821 rps already observed.\n\n### Data integrity risk (high concern)\n- Webhook payloads held in `WebhookBuffer` at the time of OOMKill are **silently dropped** \u2014 there is no evidence of a durable queue or dead-letter mechanism in the diff. This is a **data loss scenario**, not just availability.\n\n---\n\n## 4. Recommended Mitigations\n\n### Immediate actions (do now, in order)\n\n**Step 1 \u2014 Roll back `v4.18.0` to `v4.17.4`** *(< 2 minutes, highest leverage)*\n\nThis is the only action that breaks the OOM death loop. Everything else is treating symptoms.\n\n```bash\nkubectl set image deployment/payments-api \\\n  payments-api=payments-api:v4.17.4 --record\n\nkubectl rollout status deployment/payments-api\n```\n\nWatch for `READY` pods before proceeding \u2014 confirm RSS drops back toward 80\u2013100 MB p95.\n\n**Step 2 \u2014 Shed retry storm load while rollback propagates**\n\nWhile old pods are terminating and new ones starting, the retry storm will continue to hit the transition. Temporarily cut gateway retry budget to 0 or 1 for `payments-api` upstream to stop amplification:\n\n```yaml\n# api-gateway config patch\nupstream payments-api:\n  retry_budget: 1   # was 3, temporarily drop to 1\n  retry_on: 5xx     # keep, but limit volume\n```\n\nThis breaks the feedback loop immediately. Restore to 3 after rollback is confirmed stable.\n\n**Step 3 \u2014 Confirm no pod is still running `v4.18.0`**\n\n```bash\nkubectl get pods -l app=payments-api \\\n  -o jsonpath='{range .items[*]}{.metadata.name}{\"\\t\"}{.spec.containers[0].image}{\"\\n\"}{end}'\n```\n\n**Step 4 \u2014 Check for webhook payload data loss**\n\nQuery your webhook delivery logs or outbox table for any events timestamped between `14:18:14Z` and rollback completion that have status `pending` or `null`. These payloads were in-memory and are gone. Depending on business impact, you may need to:\n- Replay from the source event log if one exists\n- Alert the on-call for the payments domain team to assess financial/compliance impact\n\n---\n\n### Short-term (within 24 hours)\n\n**Add a memory-based rollout gate in CI/CD**\n- RSS p95 should not increase more than 20% from baseline within 5 minutes of deploy\n- Auto-rollback if threshold breached during canary\n\n**Add OOMKill alerting with shorter lag**\n- Current alert fired 20 minutes after deploy; first OOMKill was at 14:30 (12 min post-deploy)\n- Alert on `kube_pod_container_status_last_terminated_reason == \"OOMKilled\"` with a 1-occurrence threshold, no aggregation delay\n\n**Add a circuit breaker at the gateway for payments-api**\n- A half-open circuit breaker would have shed load automatically when error rate exceeded ~20%, preventing retry storm from reaching 389 rps\n\n---\n\n### Before re-shipping `v4.18.0`\n\n**Fix the `WebhookBuffer` design:**\n1. **Replace in-memory buffering with a durable queue** (Redis, Kafka, or a DB outbox). Memory footprint returns to baseline; payloads survive pod death.\n2. If in-memory buffer is kept for performance, **cap buffer size per pod** and apply backpressure rather than accepting unbounded memory growth.\n3. **Load test against memory limits** \u2014 210 MB/request baseline means the pod limit needs to be at least 512 MB \u00d7 (1 / 210 MB) requests in flight, which at 420 rps is unsustainable without a limit increase or architecture change.\n4. **Raise the memory limit** if buffering is retained \u2014 at minimum `requests: 256Mi, limits: 1Gi` based on observed ~210 MB p95 footprint, with VPA monitoring.\n\n---\n\n## Summary\n\nThe incident was caused by `v4.18.0` introducing an in-memory `WebhookBuffer` that increased per-request memory footprint 2.6\u00d7, exhausting the 512 MB pod limit under normal traffic within ~12 minutes. Pod OOMKills triggered gateway retries which amplified traffic ~97\u00d7, accelerating further OOMKills in a positive feedback loop. **Immediate action is to roll back to `v4.17.4` and temporarily suppress gateway retries.** There is a probable data loss component for in-flight webhook payloads that requires separate investigation.",
            "agent_chars": 9552,
            "agent_s": 71.4,
            "elapsed_s": 81.2
          },
          {
            "incident": "01-oom-cascade",
            "condition": "treatment",
            "trial": 0,
            "score": 6,
            "scores_by_item": [
              1,
              1,
              1,
              1,
              1,
              1,
              0
            ],
            "notes": [
              "Agent correctly identifies T0 as 2026-03-12T14:32:00Z, exactly matching the reference.",
              "Agent correctly identifies exactly 1 change in the window: deploy payments-api v4.18.0 (commit 9f3a2c1).",
              "Agent classifies the failure as OOM (with cascading-failure amplification), satisfying the OOM classification requirement.",
              "Agent cites 6 distinct signals from 5+ source categories: pod events (orchestrator), application logs, metrics, traces, and change events \u2014 well exceeding the 3-signal threshold.",
              "Agent quantifies users affected (~202 unique-request failures/second), surfaces affected (payments-api all pods, api-gateway, ledger-svc unaffected), and business impact (critical, payment SLAs being breached, revenue impact).",
              "Agent's top recommended action is 'kubectl rollout undo deployment/payments-api' reverting to v4.17.4, matching the reference mitigation.",
              "Agent produces a detailed timeline and ranked hypotheses/confirming signals but the handoff section is incomplete \u2014 it lacks explicitly labeled 'open questions' and the timeline is cut off mid-sentence; all four required elements (timeline, ranked hypotheses, mitigation, open questions) are not clearly present."
            ],
            "agent_output": "# kubectl-investigator \u2014 Incident Report\n## payments-api \u00b7 2026-03-12\n\n---\n\n## 1. Anchored Window\n\n**T0 = 2026-03-12T14:32:00Z** (alert fire time: `payments_api_error_rate > 5%`; source is a Prometheus/Alertmanager alert, so the alert fire time is used verbatim per step 1, rule 1).\n\n**Tnow = 2026-03-12T14:36:00Z** (investigation trigger time).\n\n**Investigation window: `[2026-03-12T14:17:00Z, 2026-03-12T14:36:00Z]`** (T0 \u2212 15 min = 14:17:00Z).\n\nT0 is unambiguous (alert-triggered). No re-run flag required.\n\n---\n\n## 2. Change Surface\n\nChanges overlapping the window `[14:17:00Z, 14:36:00Z]`:\n\n| Type | Resource | Timestamp | Summary |\n|---|---|---|---|\n| **Workload rollout** | `payments-api` \u2192 **v4.18.0** (commit `9f3a2c1`) | `2026-03-12T14:18:14Z` | Introduced `WebhookBuffer`: per-request memory footprint increased from ~80 MB to ~210 MB (`internal/webhook/buffer.go`) |\n\nAdditional context (outside window, included for reference only):\n- `payments-api` v4.17.4 (commit `a1b8e09`) deployed `2026-03-10T09:14:22Z` \u2014 increased ledger-svc retry budget from 2 \u2192 3. This is **outside** the 15-minute lead-in and is not causally implicated in this incident, but is noted as a compounding factor (see Ranked Hypotheses).\n\n**No other changes in window:**\n- Infrastructure changes: none\n- RBAC / ServiceAccount changes: none\n- ConfigMap / Secret / CoreDNS changes: none\n- HPA / PDB / VPA changes: none\n- CronJobs / Jobs: none\n- Feature flags: none\n\n**Change surface is narrow and unambiguous: one rollout, one diff hunk, directly touching memory allocation.**\n\n---\n\n## 3. Classified Path\n\n### Classification: **OOM \u2192 Cascading-failure (OOM-triggered retry storm)**\n\nThis is a **compound path** anchored on **OOM**, with a secondary **cascading-failure** amplification loop. It maps cleanly to the canonical example `examples/01-oom-cascade.md`.\n\n**OOM tell-tales satisfied:**\n- Container exit code 137, `reason: OOMKilled` across all pods.\n- RSS p95 climbs from ~80 MB (pre-rollout baseline) to ~510 MB (at limit) over ~12 minutes, saturating the 512 Mi limit.\n- `runtime: out of memory` / `heap_inuse_bytes` > 503 MB in container logs.\n- Rollout diff explicitly documents +130 MB per-request footprint \u2014 this is the mechanism.\n\n**Cascading-failure amplification tell-tales satisfied:**\n- api-gateway begins retrying OOMKilled pods \u2192 surviving pods receive more load \u2192 they OOM faster \u2192 more pods fall \u2192 api-gateway retries escalate \u2192 loop tightens.\n- `gateway_retry_rate_rps` escalates non-linearly: 4 \u2192 5 \u2192 6 \u2192 8 \u2192 41 \u2192 184 \u2192 312 \u2192 389 rps (roughly doubling every minute after 14:31Z).\n- `request_rate_rps` climbs from ~412 to ~821 rps \u2014 a 2\u00d7 apparent load amplification that is entirely retry-driven, not organic traffic growth.\n- Error rate: 0.3% \u2192 0.4% (stable pre-OOM) \u2192 7.4% (first OOM wave) \u2192 24% \u2192 38% \u2192 46.8% (accelerating as pod count shrinks).\n\n**Why this is OOM-primary, not a pure cascade:**\nThe cascade has no degraded upstream dependency as its origin \u2014 there is no slow DB, no SERVFAIL, no latency spike in `ledger-svc`. The root origin is memory exhaustion in `payments-api` driven by the new `WebhookBuffer` allocation pattern. The cascade is a consequence of that exhaustion, not the cause.\n\n---\n\n## 4. Confirming Signals\n\nThree independent signals are required; six are available here, each from a distinct source.\n\n| # | Source | Signal |\n|---|---|---|\n| **S1** | **Pod events** (`pod_events.jsonl`) | Six distinct pods across two OOM waves show `reason: OOMKilled`, exit code 137, all running on the `7d9c` ReplicaSet that corresponds to v4.18.0. First OOM at `14:30:12Z`, within 12 minutes of the rollout completing at `14:18:35Z`. |\n| **S2** | **Application logs** (`logs.jsonl`) | `runtime: out of memory` FATAL entries across three separate pod IDs (`x4k2`, `m2n8`, `b7q1`), with `heap_inuse_bytes` in the range 503\u2013507 MB \u2014 within 1\u20134% of the 512 Mi container limit. GC pause warning at `14:30:11Z` is the pre-OOM canary. |\n| **S3** | **Metrics** (`metrics.json`) | RSS p95 trajectory: 80 MB (14:17, pre-rollout) \u2192 214 MB (14:20, immediately post-rollout) \u2192 398 MB (14:25) \u2192 491 MB (14:29) \u2192 510 MB (14:30+). The jump from ~80 MB to ~214 MB between the 14:17 and 14:20 samples is the exact inflection caused by the `WebhookBuffer` change (+130 MB/request matches the diff). `gateway_retry_rate_rps` then follows the OOM wave non-linearly. |\n| **S4** | **Distributed traces** (`traces.jsonl`) | Pre-rollout baseline traces (`tr-baseline-001`, `tr-baseline-002`) show `payments-api` span \u2248 78\u201381 ms, zero retries, success. First failure trace (`tr-first-fail-001`) at `14:30:14Z` shows `payments-api` span = 502 ms, `connection_reset` \u2014 consistent with a pod dying mid-request. By `14:33:12Z` (`tr-storm-014`) the span is 1,204 ms with `context_deadline_exceeded`, consistent with surviving pods under extreme saturation. |\n| **S5** | **Change events** (rollout diff, `deploys.json`) | v4.18.0 diff explicitly states per-request memory footprint ~80 MB \u2192 ~210 MB. Rollout completed at `14:18:35Z` (last pod started). First OOM at `14:30:12Z` = ~11.5 minutes after rollout completion, consistent with buffers filling under ~420 rps steady-state load. |\n| **S6** | **Gateway logs / api-gateway error logs** (`logs.jsonl`) | `upstream error` (502) at `14:30:14Z`, `retry attempt 1` at `14:30:15Z`, `retry budget exhausted` (3 attempts) at `14:32:01Z`. The api-gateway is actively driving the retry storm that amplifies load onto surviving pods. |\n\n**No per-region or per-shard asymmetry detected.** All pods in the failing ReplicaSet (`7d9c`) exhibit the same OOM pattern; the failure is uniform across the Deployment, consistent with a code/memory regression affecting every pod identically. No asymmetry short-circuit applies.\n\n---\n\n## 5. Blast Radius\n\n| Dimension | Estimate |\n|---|---|\n| **Users affected** | Growing rapidly. Error rate at `14:34:00Z` = **46.8%** of in-flight requests, at ~821 rps (of which ~389 rps are gateway retries). Real user-visible failures \u2248 46.8% \u00d7 (821 \u2212 389) = ~202 unique-request failures/second and rising. |\n| **Trajectory** | Accelerating. Pod count is shrinking (6 pods OOMKilled in 4 minutes, restarted pods are immediately hitting the same memory ceiling and OOMKilling again). Without intervention, the Deployment converges on **0 healthy pods**. |\n| **Surfaces affected** | `payments-api` (all pods, single ReplicaSet `7d9c`, all replicas). Downstream: `api-gateway` is generating a retry storm that is saturating `payments-api` further. `ledger-svc` appears unaffected (healthy in all pre-OOM traces; no signals from it post-OOM). |\n| **Namespaces / clusters** | Single cluster, single namespace (payments). No multi-region signal. |\n| **Business impact** | **Critical.** payments-api is the payment processing path. ~50% of payment requests are failing and error rate is still climbing. Every minute without mitigation increases the fraction. SLO burn rate is high; contractual payment SLAs are likely being breached. Revenue impact is proportional to the payment volume being dropped. |\n\n---\n\n## 6. Recommended Mitigation\n\n**Classification is OOM + cascading-failure (not \"outside reference paths\"). T0 is unambiguous. The rollout has `bundle_size = 1` (single diff hunk). Standard mitigation order applies.**\n\n### Immediate actions (execute now, in order):\n\n**Action 1 \u2014 `kubectl rollout undo`**\n\n```bash\nkubectl rollout undo deployment/payments-api -n payments\n```\n\nThis reverts v4.18.0 \u2192 v4.17.4 and eliminates the `WebhookBuffer` memory footprint increase. Once new pods from the previous ReplicaSet become Ready, RSS will return to ~80 MB/pod and OOM events will stop.\n\n- **Rollback is safe:** `bundle_size = 1`; the rollout contains exactly one change (the `WebhookBuffer` introduction). No unrelated code paths are being reverted.\n- **Monitoring:** Watch `kubectl rollout status deployment/payments-api -n payments` and the RSS p95 metric. Expect RSS to drop below 100 MB within 2\u20133 minutes of pods becoming Ready.\n- **Expected effect on the cascade:** Once rolled-back pods are healthy and passing readiness probes, the api-gateway will route to them. Retry rate will collapse as upstream 502s cease. The cascade self-heals once the OOM source is removed.\n\n**Action 2 \u2014 Cap the api-gateway retry budget (immediate, parallel to Action 1)**\n\nThe api-gateway retry budget is currently amplifying load by ~90% (389/821 rps is retry traffic). While the rollback is in progress, reduce the api-gateway's retry budget for the `payments-api` upstream to **0 or 1** to prevent the retry storm from continuing to kill newly-started pods before they can stabilise.\n\n```bash\n# Implementation is gateway-specific; e.g. for Envoy/Istio VirtualService:\nkubectl patch virtualservice payments-api -n payments \\\n  --type=merge \\\n  -p '{\"spec\":{\"http\":[{\"retries\":{\"attempts\":0}}]}}'\n# Or apply the equivalent EnvoyFilter / NGINX annotation change for your gateway.\n```\n\n> \u26a0\ufe0f **\"Does not address cause\"** \u2014 this action treats the amplification symptom. It will reduce pressure on recovering pods but does not fix the OOM. Restore the retry budget to its normal value after the rollback is confirmed healthy.\n\n**Action 3 \u2014 Optionally scale up replicas during rollback transition (if rollout is slow)**\n\nIf the rollback takes longer than ~2 minutes and error rate continues to climb, temporarily increase the replica count to provide capacity headroom while v4.17.4 pods come up:\n\n```bash\nkubectl scale deployment/payments-api -n payments --replicas=<current_count + 3>\n```\n\n> \u26a0\ufe0f **\"Does not address cause\"** \u2014 scaling does not fix the memory regression; v4.18.0 pods will continue to OOM at the same rate. This action only buys time during the rollback transition. Trim replica count back after rollback is complete and RSS is confirmed healthy.\n\n### Actions explicitly *not* recommended:\n\n- **`kubectl delete pod` (manual pod kill)**: pods are already restarting via OOMKill; manual deletion does not fix the spec and adds churn.\n- **Raising `resources.limits.memory`**: the correct limit for this workload should be sized for the *correct* code, not for a regression. Raising the limit papers over the bug and defers the fix; it is also insufficient \u2014 at ~210 MB/request \u00d7 420 rps, even a raised limit will be consumed quickly.\n- **Traffic-shift to another cluster/region**: no healthy peer cluster is identified in the telemetry. Not applicable here.\n\n---\n\n## 7. Handoff Payload for `postmortem-author`\n\n---\n\n### Timeline\n\n| Timestamp | Event |\n|---|---|\n| `2026-03-10T09:14:22Z` | v4.17.4 deployed (outside incident window): ledger-svc retry budget increased 2 \u2192 3. Background context only. |\n| `2026-03-12T14:17:00Z` | **Window open (T0 \u2212 15 min).** RSS p95 = ~80 MB, error rate = 0.3%, gateway retry rate = 4 rps. Cluster healthy. |\n| `2026-03-12T14:18:14Z` | **v4.18.0 rollout begins** (ci-cd). Introduces `WebhookBuffer` in `internal/webhook/buffer.go`. |\n| `2026-03-12T14:18:30Z` | Pod `x4k2` started on new ReplicaSet `7d9c`. |\n| `2026-03-12T14:18:32Z` | Pod `m2n8` started. |\n| `2026-03-12T14:18:35Z` | Pod `b7q1` started. Rollout complete (3-pod Deployment, all pods up). |\n| `2026-03-12T14:20:00Z` | RSS p95 jumps to ~214 MB (from ~80 MB pre-rollout). Memory footprint increase from `WebhookBuffer` is immediately visible. |\n| `2026-03-12T14:25:00Z` | RSS p95 = ~398 MB. Error rate still nominal (0.4%). Baseline traces (`tr-baseline-001`) still succeeding. |\n| `2026-03-12T14:29:00Z` | RSS p95 = ~492 MB (96% of 512 Mi limit). Error rate 0.6%. GC pressure increasing. |\n| `2026-03-12T14:30:11Z` | GC pause WARN on pod `x4k2` (187 ms). Pre-OOM canary. |\n| `2026-03-12T14:30:12Z` | **First OOMKill: pod `x4k2`.** `runtime: out of memory`, heap_inuse = 504 MB. Exit code 137. |\n| `2026-03-12T14:30:14Z` | api-gateway receives 502 from `payments-api`. First failure trace (`tr-first-fail-001`). |\n| `2026-03-12T14:30:15Z` | api-gateway begins retrying (`attempt: 1`). Gateway retry storm begins. |\n| `2026-03-12T14:30:48Z` | **Second OOMKill: pod `m2n8`.** |\n| `2026-03-12T14:31:00Z` | Error rate = 7.4%. Gateway retry rate = 41 rps. Surviving pods now receiving amplified load. |\n| `2026-03-12T14:31:22Z` | **Third OOMKill: pod `b7q1`.** All original rollout pods have now OOMKilled at least once. |\n| `2026-03-12T14:32:00Z` | **T0 \u2014 Alert fires: `payments_api_error_rate > 5%`.** Error rate = 24.1%. Gateway retry rate = 184 rps. Request rate = 612 rps. |\n| `2026-03-12T14:32:05Z` | **Fourth OOMKill: pod `x4k2`** (restarted pod OOMKills again \u2014 same broken spec). |\n| `2026-03-12T14:33:00Z` | Error rate = 38.2%. Gateway retry rate = 312 rps. Cascade",
            "agent_chars": 12620,
            "agent_s": 85.5,
            "elapsed_s": 94.4
          },
          {
            "incident": "01-oom-cascade",
            "condition": "treatment",
            "trial": 1,
            "score": 6,
            "scores_by_item": [
              1,
              1,
              1,
              1,
              1,
              1,
              0
            ],
            "notes": [
              "Agent correctly identifies T0 as 2026-03-12T14:32:00Z, exactly matching the reference.",
              "Agent correctly identifies exactly 1 change in the window: deploy payments-api v4.18.0 (commit 9f3a2c1).",
              "Agent explicitly classifies the failure as OOM with cascade amplification, matching the reference classification.",
              "Agent cites four independent signal sources: pod/cluster events (orchestrator), metrics, application logs, and distributed traces.",
              "Agent quantifies users affected (~24-47% of requests failing), surfaces affected (payments-api, api-gateway, potential ledger-svc impact), and business impact (revenue loss, SLO burn, contractual risk).",
              "Agent's top recommended action is 'kubectl rollout undo deployment/payments-api' reverting to v4.17.4, matching the reference mitigation.",
              "Agent provides a timeline and recommended mitigation but does not include a clearly labeled 'ranked hypotheses' section or 'open questions' section \u2014 the handoff section is incomplete/cut off and lacks all four required components."
            ],
            "agent_output": "# kubectl-investigator \u2014 payments-api Incident\n\n---\n\n## 1. Anchored Window\n\n**T0 = 2026-03-12T14:32:00Z** (alert fire time: `payments_api_error_rate > 5%`; rule 1 applies \u2014 alert fire time is used verbatim as the coordination anchor).\n\n**Tnow = 2026-03-12T14:36:00Z** (investigation trigger time).\n\n**Investigation window = [2026-03-12T14:17:00Z, 2026-03-12T14:36:00Z]** (T0 \u2212 15 min through Tnow).\n\nT0 is unambiguous \u2014 the alert fired at a precise timestamp. No ambiguity flag.\n\n---\n\n## 2. Change Surface\n\nChanges overlapping `[2026-03-12T14:17:00Z, 2026-03-12T14:36:00Z]`:\n\n| Category | Change | Timestamp | Detail |\n|---|---|---|---|\n| **Workload rollout** | `payments-api` v4.18.0 deployed | `2026-03-12T14:18:14Z` | `internal/webhook/buffer.go`: introduces `WebhookBuffer` holding full payload bodies in memory before batch-writing. Per-request memory footprint increased from **~80 MB \u2192 ~210 MB**. Commit `9f3a2c1`. Deployed by CI/CD. |\n\n- **Infra changes**: none\n- **RBAC changes**: none\n- **ConfigMap / Secret changes**: none\n- **Feature flags**: none\n- **CronJobs / Jobs**: none\n- **`bundle_size`**: 1 (single-change rollout \u2014 the `rollout undo` escalation path for multi-change bundles does not apply)\n\nThe prior deploy, v4.17.4 (`2026-03-10T09:14:22Z`), is outside the window. The only in-window change is the v4.18.0 rollout.\n\n---\n\n## 3. Classified Path\n\n**Primary path: OOM \u2192 triggering a cascading retry storm (reference path: OOM, with secondary cascade amplification)**\n\n### Fit to the OOM reference path\n\n| Tell-tale | Present? | Evidence |\n|---|---|---|\n| Container restart count climbing / `CrashLoopBackOff` | \u2705 | 6 `OOMKilled` events across 5 pods over 4 minutes |\n| Exit code 137 | \u2705 | All pod events: \"Exit code 137\" |\n| `reason: OOMKilled` in pod events | \u2705 | `payments-api-7d9c-x4k2`, `-m2n8`, `-b7q1`, `-r5j7`, `-c9w4` |\n| RSS / working-set at memory limit | \u2705 | p95 RSS reached 511 MB against a 512 MiB (536,870,912 B) limit |\n| Recent rollout that increased per-pod memory footprint | \u2705 | v4.18.0 diff explicitly documents ~80 MB \u2192 ~210 MB per-request increase |\n| Retry storm from upstream | \u2705 | api-gateway retry rate: 4 rps \u2192 389 rps over 4 minutes |\n\n### Secondary cascade amplification\n\nOnce the first pods begin OOM-killing (14:30), the api-gateway's retry logic floods surviving pods with redirected traffic. Each retry carries a new in-flight `WebhookBuffer` payload, accelerating RSS growth on the remaining healthy pods, causing them to OOM-kill in turn. The cascade is a *consequence* of the OOM root cause, not an independent origin; the cascade path has no independent trigger (no degraded dependency, no DB slowdown). Classification remains **OOM** with cascade amplification as the propagation mechanism.\n\nNo regional or shard asymmetry is present (all pods affected, single cluster/region). The four-path search does not short-circuit to \"outside reference paths\".\n\n---\n\n## 4. Confirming Signals (Three Independent Sources)\n\n### Signal 1 \u2014 Pod / cluster events (source: `pod_events.jsonl`)\n\nSix `OOMKilled` events with exit code 137, spanning pods `x4k2`, `m2n8`, `b7q1`, `x4k2` (second kill), `r5j7`, `c9w4`. First OOM at **14:30:12Z**, 11 minutes 58 seconds after the v4.18.0 rollout completed pod starts (~14:18:35Z). The sequence correlates precisely with progressive RSS growth visible in metrics.\n\n### Signal 2 \u2014 Metrics (source: `metrics.json`)\n\nRSS trajectory against the 512 MiB limit:\n\n| Timestamp | p95 RSS | % of limit | Error rate | GW retry rate |\n|---|---|---|---|---|\n| 14:17:00Z (pre-deploy) | 80 MB | 14.9% | 0.3% | 4 rps |\n| 14:20:00Z (+2 min post-deploy) | 214 MB | 39.8% | 0.4% | 5 rps |\n| 14:25:00Z | 380 MB | 70.7% | 0.4% | 4 rps |\n| 14:29:00Z | 469 MB | 87.5% | 0.6% | 6 rps |\n| 14:30:00Z | 487 MB | 90.7% | 0.8% | 8 rps |\n| 14:31:00Z | 487 MB | 90.8% | 7.4% | 41 rps |\n| 14:32:00Z (T0, alert fires) | 488 MB | 90.9% | 24.1% | 184 rps |\n| 14:33:00Z | 488 MB | 90.9% | 38.2% | 312 rps |\n| 14:34:00Z | 488 MB | 91.1% | 46.8% | 389 rps |\n\nRSS climbs immediately on deploy (80 MB \u2192 214 MB in 3 minutes, consistent with the documented ~80 MB \u2192 ~210 MB per-request increase). RSS saturates the 512 MiB limit by ~14:30, triggering OOM kills. The retry storm then shows as both a spike in `request_rate_rps` (412 \u2192 821 rps) and `gateway_retry_rate_rps` (4 \u2192 389 rps), reflecting the cascade amplification.\n\n*Note:* the p95 RSS values after 14:31 plateau near 488\u2013489 MB rather than climbing further. This is consistent with the cascade phase: pods are being killed before they can grow past the limit; surviving pods absorb redirected traffic and are killed in turn. The \"flat ceiling\" is an artefact of the kill/restart cycle, not a recovery.\n\n### Signal 3 \u2014 Application logs (source: `logs.jsonl`)\n\n- `14:30:11Z`: GC pause 187 ms on `x4k2` (pre-OOM pressure warning).\n- `14:30:12Z`: `runtime: out of memory`, `heap_inuse_bytes: 504,628,224` (~481 MB) \u2014 pod `x4k2`.\n- `14:30:48Z` and `14:31:22Z`: identical `runtime: out of memory` on `m2n8` and `b7q1`.\n- `14:30:14Z`\u2013`14:30:15Z`: api-gateway logs 502 upstream error then retry attempt 1 for `req-c9d2e1`.\n- `14:32:01Z`: api-gateway logs `retry budget exhausted` after 3 attempts.\n- `14:32:05Z`: fourth OOM kill event on `x4k2` (pod restarted and hit OOM again within ~2 minutes).\n\nThese log entries independently confirm both the OOM origin and the gateway retry amplification, from two different services' log streams.\n\n### Signal 4 (bonus) \u2014 Distributed traces (source: `traces.jsonl`)\n\nBaseline traces at 14:25 and 14:29:30 show `payments-api` responding in 78\u201381 ms with zero retries and `ledger-svc` healthy (22\u201324 ms). The causal chain is isolated to `payments-api`:\n\n- **14:30:14Z**: `payments-api` span duration 502 ms, error `connection_reset` \u2014 first failure.\n- **14:30:16Z**: retry attempt, `payments-api` span 488 ms, `connection_reset` again.\n- **14:32:01Z**: api-gateway span only (`retry_budget_exhausted`) \u2014 `payments-api` unreachable.\n- **14:33:12Z**: `payments-api` span 1,204 ms, `context_deadline_exceeded` \u2014 surviving pods overwhelmed by retry traffic.\n\n`ledger-svc` does not appear in any failing trace; it is not a contributor. The cascade originates entirely within `payments-api` and the api-gateway retry layer.\n\nAll four signals are from independent sources (cluster events, metrics pipeline, application logs, distributed tracing). The three-signal bar is met; a fourth provides additional confidence.\n\n---\n\n## 5. Blast Radius\n\n### Users affected\n- Based on the error-rate trajectory, at T0 (14:32:00Z) **24.1% of payments-api requests are failing**. By 14:34:00Z (the last sample before investigation trigger) this has reached **46.8%** and is still climbing as pods continue to OOM-kill.\n- Request rate has inflated from 412 rps to 821 rps due to gateway retries, meaning real-user traffic is being retried multiple times;
      • incidents.py 8.6 KB
        """
        Per-fixture incident contexts and expected answers, used by run_eval.py.
        
        The "expected_*" fields are the deterministic answers from _methodology.py
        run against each fixture (see tests/replay_*.py). They are the source of
        truth the judge model compares the agent's output against.
        
        Stdlib only. No external dependencies.
        """
        
        from __future__ import annotations
        
        from pathlib import Path
        
        FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent / "fixtures"
        
        # Each entry: a fixture-specific incident the eval will run agents against.
        # Keep this list aligned with the replay_*.py files under tests/.
        
        INCIDENTS = [
            {
                "id": "01-oom-cascade",
                "fixture_dir": FIXTURES_DIR / "01-oom-cascade",
                "service": "payments-api",
                "alert_time": "2026-03-12T14:32:00Z",
                "alert_message": "payments_api_error_rate > 5%",
                "tnow": "2026-03-12T14:36:00Z",
                "failing_surface_hints": ["webhook", "buffer"],
                "expected_t0": "2026-03-12T14:32:00Z",
                "expected_change_count": 1,
                "expected_change_summary": "deploy payments-api v4.18.0 (commit 9f3a2c1) in window",
                "expected_path": "OOM",
                "expected_top_mitigation": "revert payments-api to v4.17.4",
                "expected_escalate": False,
            },
            {
                "id": "02-dns-resolution-failure",
                "fixture_dir": FIXTURES_DIR / "02-dns-resolution-failure",
                "service": "inventory-svc",
                "alert_time": "2026-04-08T09:47:00Z",
                "alert_message": "inventory_svc_error_rate > 3%",
                "tnow": "2026-04-08T09:53:00Z",
                "failing_surface_hints": ["coredns", ".internal"],
                "expected_t0": "2026-04-08T09:47:00Z",
                "expected_change_count": 1,
                "expected_change_summary": "kube-system/coredns ConfigMap edit (typo in .internal forward)",
                "expected_path": "DNS",
                "expected_top_mitigation": "revert the kube-system/coredns ConfigMap",
                "expected_escalate": False,
            },
            {
                "id": "03-cascading-failure-retry-storm",
                "fixture_dir": FIXTURES_DIR / "03-cascading-failure-retry-storm",
                "service": "payments-api",
                "alert_time": "2026-03-20T11:08:00Z",
                "alert_message": "payments_api_latency_p99 > 1000ms",
                "tnow": "2026-03-20T11:15:00Z",
                "failing_surface_hints": [],
                "expected_t0": "2026-03-20T11:08:00Z",
                "expected_change_count": 0,
                "expected_change_summary": "zero changes in window",
                "expected_path": "cascading-failure",
                "expected_top_mitigation": "open circuit breaker on the upstream ledger-svc call path",
                "expected_escalate": False,
            },
            {
                "id": "04-deploy-correlator-serialization",
                "fixture_dir": FIXTURES_DIR / "04-deploy-correlator-serialization",
                "service": "checkout-api",
                "alert_time": "2026-02-15T13:25:00Z",
                "alert_message": "checkout_api_error_rate > 2%",
                "tnow": "2026-02-15T13:30:00Z",
                "failing_surface_hints": ["cart", "serializer"],
                "expected_t0": "2026-02-15T13:25:00Z",
                "expected_change_count": 1,
                "expected_change_summary": "deploy checkout-api v6.4.0 (commit d1c4e22)",
                "expected_path": "deploy-correlator",
                "expected_top_mitigation": "revert checkout-api to v6.3.7",
                "expected_escalate": False,
            },
            {
                "id": "05-outside-reference-paths-third-party-rate-limit",
                "fixture_dir": FIXTURES_DIR / "05-outside-reference-paths-third-party-rate-limit",
                "service": "payments-api",
                "alert_time": "2026-05-04T16:44:00Z",
                "alert_message": "payments_api_error_rate > 2%",
                "tnow": "2026-05-04T16:50:00Z",
                "failing_surface_hints": [],
                "expected_t0": "2026-05-04T16:44:00Z",
                "expected_change_count": 0,
                "expected_change_summary": "zero changes in window",
                "expected_path": "outside-reference-paths",
                "expected_top_mitigation": "escalate to a human (third-party rate limit hypothesis)",
                "expected_escalate": True,
            },
            {
                "id": "06-ambiguous-t0-slow-burn",
                "fixture_dir": FIXTURES_DIR / "06-ambiguous-t0-slow-burn",
                "service": "recommendations-api",
                "alert_time": "2026-04-19T12:15:00Z",
                "alert_message": "operator-triggered investigation: 'slow all morning'",
                "tnow": "2026-04-19T12:15:00Z",
                "failing_surface_hints": [],
                "expected_t0": "2026-04-19T09:32:14Z",
                "expected_change_count": 0,
                "expected_change_summary": "zero changes in window (causal deploy is outside window)",
                "expected_path": "OOM",
                "expected_top_mitigation": "re-run investigation with widened window before acting",
                "expected_escalate": True,
            },
            {
                "id": "07-blast-radius-asymmetric-revert",
                "fixture_dir": FIXTURES_DIR / "07-blast-radius-asymmetric-revert",
                "service": "notifications-svc",
                "alert_time": "2026-06-02T10:03:00Z",
                "alert_message": "notifications_sms_error_rate > 50%",
                "tnow": "2026-06-02T10:10:00Z",
                "failing_surface_hints": ["twilio", "sms"],
                "expected_t0": "2026-06-02T10:03:00Z",
                "expected_change_count": 1,
                "expected_change_summary": "deploy notifications-svc v8.12.0 bundle of 6 PRs",
                "expected_path": "deploy-correlator",
                "expected_top_mitigation": "revert v8.12.0 (escalation: M3 bundle of 6 changes, broader blast radius)",
                "expected_escalate": True,
            },
            {
                "id": "08-deploy-correlator-confirmation-bias",
                "fixture_dir": FIXTURES_DIR / "08-deploy-correlator-confirmation-bias",
                "service": "users-api",
                "alert_time": "2026-07-11T14:33:00Z",
                "alert_message": "users_api_error_rate > 2%",
                "tnow": "2026-07-11T14:38:00Z",
                "failing_surface_hints": ["secret", "auth", "rbac"],
                "expected_t0": "2026-07-11T14:33:00Z",
                "expected_change_count": 2,
                "expected_change_summary": "deploy (innocent) + RBAC RoleBinding deletion (the actual cause)",
                "expected_path": "outside-reference-paths",
                "expected_top_mitigation": "escalate; the deploy is NOT the cause; investigate RBAC RoleBinding revert",
                "expected_escalate": True,
            },
            {
                "id": "09-zero-changes-external-cert-expiry",
                "fixture_dir": FIXTURES_DIR / "09-zero-changes-external-cert-expiry",
                "service": "webhook-receiver",
                "alert_time": "2026-08-20T03:18:00Z",
                "alert_message": "webhook_receiver_error_rate > 4%",
                "tnow": "2026-08-20T03:25:00Z",
                "failing_surface_hints": [],
                "expected_t0": "2026-08-20T03:18:00Z",
                "expected_change_count": 0,
                "expected_change_summary": "zero changes in window",
                "expected_path": "outside-reference-paths",
                "expected_top_mitigation": "escalate; external partner TLS cert expired",
                "expected_escalate": True,
            },
            {
                "id": "10-multi-region-asymmetry",
                "fixture_dir": FIXTURES_DIR / "10-multi-region-asymmetry",
                "service": "image-svc",
                "alert_time": "2026-09-14T11:42:00Z",
                "alert_message": "image_svc_error_rate > 5% (aggregate)",
                "tnow": "2026-09-14T11:50:00Z",
                "failing_surface_hints": [],
                "expected_t0": "2026-09-14T11:42:00Z",
                "expected_change_count": 0,
                "expected_change_summary": "zero changes in window; regional asymmetry us-east-1 vs us-west-2",
                "expected_path": "outside-reference-paths",
                "expected_top_mitigation": "traffic-shift to us-west-2; escalate config-drift investigation",
                "expected_escalate": True,
            },
            {
                "id": "11-capacity-bound-organic-growth",
                "fixture_dir": FIXTURES_DIR / "11-capacity-bound-organic-growth",
                "service": "search-svc",
                "alert_time": "2026-10-08T10:55:00Z",
                "alert_message": "search_svc_error_rate > 3%",
                "tnow": "2026-10-08T11:05:00Z",
                "failing_surface_hints": [],
                "expected_t0": "2026-10-08T10:55:00Z",
                "expected_change_count": 0,
                "expected_change_summary": "zero changes in window; sustained request-rate growth",
                "expected_path": "outside-reference-paths",
                "expected_top_mitigation": "scale search-svc horizontally",
                "expected_escalate": True,
            },
        ]
        
        
        def load_fixture_text(fixture_dir: Path) -> str:
            """Concatenate the fixture files into a single text blob for prompt embedding."""
            parts = []
            for name in ("deploys.json", "pod_events.jsonl", "metrics.json", "logs.jsonl", "traces.jsonl"):
                path = fixture_dir / name
                if not path.exists():
                    continue
                parts.append(f"=== {name} ===\n{path.read_text()}")
            return "\n\n".join(parts)
        
      • judge_prompt.md 2.4 KB
        # Judge prompt template
        
        Used by `run_eval.py` to grade an agent's investigation output against the 7-item rubric. The judge model is given the agent's output, the expected answers (from the deterministic reference implementation), and the rubric. It returns a JSON object with 7 binary scores plus a one-line note per item.
        
        ## System prompt
        
        You are an expert SRE evaluator. You are grading an incident investigation produced by an AI agent against a 7-item rubric. The rubric is binary: each item is 1 (pass) or 0 (fail). Be strict but fair; partial credit is not awarded.
        
        You will be given:
        1. The known-good answer for the incident, generated by a deterministic reference implementation that mechanically applies the seven methodology steps.
        2. The agent's investigation output.
        3. The 7 rubric items.
        
        Your job is to assess each rubric item against the agent's output, using the known-good answer as the source of truth.
        
        Return JSON only, no prose:
        
        ```json
        {
          "scores": [<int>, <int>, <int>, <int>, <int>, <int>, <int>],
          "notes": ["<one sentence>", "<one sentence>", "<one sentence>", "<one sentence>", "<one sentence>", "<one sentence>", "<one sentence>"]
        }
        ```
        
        ## User prompt template
        
        ```
        INCIDENT: {incident_id}
        SERVICE: {service}
        
        KNOWN-GOOD ANSWER (from deterministic methodology):
        - T0: {expected_t0}
        - Change surface: {expected_change_count} change(s) in window — {expected_change_summary}
        - Classification: {expected_path}
        - Top mitigation: {expected_top_mitigation}
        - Should escalate to human: {expected_escalate}
        
        AGENT INVESTIGATION OUTPUT:
        {agent_output}
        
        RUBRIC:
        1. T0 anchor: agent picks T0 within ±2 minutes of {expected_t0}.
        2. Change surface: agent correctly enumerates {expected_change_count} change(s) in window ({expected_change_summary}).
        3. Classification: agent classifies the failure as {expected_path}.
        4. Independent signals: agent cites at least 3 signals from distinct source categories (logs, metrics, traces, change events, orchestrator events). Two signals from the same category count as one.
        5. Blast radius: agent quantifies users affected + surfaces affected + business impact (all three required for pass).
        6. Mitigation: agent's TOP recommended action matches {expected_top_mitigation}.
        7. Handoff: agent produces a structured handoff including timeline, ranked hypothesis(es), recommended mitigation, and open questions (all four required for pass).
        
        Score each item as 1 (pass) or 0 (fail). Return JSON only.
        ```
        
      • README.md 12.7 KB
        # Ablation eval
        
        Does loading `SKILL.md` measurably improve an LLM agent's incident investigation? The replay tests under `tests/replay_*.py` prove the methodology *logic* produces correct outputs against fixtures. They do not prove that an agent following the SKILL.md prose *does better* than an agent without it.
        
        This eval answers that question. It runs the same agent in two conditions (control = no skill, treatment = SKILL.md loaded) against the same fixtures, scores both against a 7-item rubric, and reports the **lift** (= treatment score - control score). A skill is "valuable" when the lift is consistently positive across fixtures.
        
        ## Quickstart
        
        ```bash
        # Install the only non-stdlib dependency
        pip install anthropic
        
        # Set your API key
        export ANTHROPIC_API_KEY=sk-ant-...
        
        # Smoke test: 1 trial per cell, 3 fixtures
        python tests/eval/run_eval.py --trials 1 --fixtures 01,03,05
        
        # Full run: 5 trials per cell, all 11 fixtures (~220 LLM calls, expect 20-50 USD)
        python tests/eval/run_eval.py --trials 5
        ```
        
        The script writes raw per-trial results to `eval_results.json` and prints a per-fixture summary table with aggregate lift and a verdict.
        
        ## Automated run results (current)
        
        The committed [`eval_results.json`](./eval_results.json) is an automated run with an LLM judge (no manual scoring). Setup:
        
        - Model: Claude Sonnet 4.6 as both agent and judge (`--agent-model`/`--judge-model` defaults)
        - Trials per cell: **N=3** (66 trials total)
        - Scoring: LLM-as-judge against [`rubric.md`](./rubric.md), anchored to the deterministic reference answer from `tests/_methodology.py`
        
        | Fixture | Control mean | Treatment mean | Lift |
        |---|---:|---:|---:|
        | 01 OOM cascade | 5.67 | 6.00 | +0.33 |
        | 02 DNS resolution failure | 5.67 | 6.33 | +0.67 |
        | 03 Cascading-failure retry storm | 6.00 | 6.00 | +0.00 |
        | 04 Deploy-correlator (serialization) | 5.67 | 6.33 | +0.67 |
        | 05 Outside paths (third-party 429) | 4.33 | 6.67 | **+2.33** |
        | 06 Ambiguous T0 (slow-burn leak) | 5.00 | 6.00 | +1.00 |
        | 07 Bundle blast-radius (6-PR train) | 6.00 | 6.00 | +0.00 |
        | 08 Confirmation bias (deploy + RBAC) | 5.00 | 6.67 | **+1.67** |
        | 09 Zero changes (cert expiry) | 6.67 | 7.00 | +0.33 |
        | 10 Multi-region asymmetry | 5.33 | 6.00 | +0.67 |
        | 11 Capacity-bound organic growth | 5.67 | 7.00 | **+1.33** |
        | **Aggregate mean** | **5.55** | **6.36** | **+0.82** |
        
        **Lift: +0.82 / 7 (+15%).** Treatment wins on 9 fixtures, ties on 2 (03, 07), and loses on none. The largest lifts are on the escalation cases the cold agent does not know to guard against. Investigation duration: treatment ~85s vs control ~57s mean (1.49x) — the treatment emits the full seven-section handoff, which is the latency cost of the lift.
        
        ### What changed in SKILL.md after the first automated run
        
        The first automated run (N=1) showed three *negative* fixtures and one pathological case. Four targeted edits fixed all of them, with zero new regressions, taking the aggregate from +0.27 (3 losses) to +0.82 (0 losses):
        
        | Failure pattern | Affected fixtures | Edit |
        |---|---|---|
        | Handoff silently dropped the "open questions" section | 03, 11 (and part of 10) | Step 7 + output format now mandate all four handoff sections explicitly, including a dedicated "open questions" heading even when empty. |
        | Outside-reference-paths forced "escalate to human" as the top action, contradicting worked example 10 and FAILURE_MODES M1 | 10 | Step 6 now treats traffic-shift to a healthy peer / feature-flag-off as pre-approved safe actions that become the top recommendation, with root cause escalated in parallel. |
        | Agent thrashed on aggregate metrics for a regional asymmetry (a single trial ran 2486s before timing out) | 10 | Step 4 now mandates splitting aggregate signals by region/cluster/shard; a confirmed asymmetry short-circuits the four-path search. Runtime dropped to ~84s. |
        | No circuit-breaker / shed-at-source mitigation for cascades | 03 | Step 6 now names "open the circuit breaker on / shed load from the degraded dependency" as the cascading-failure top action. |
        
        ## Historical reference run (manual, N=1, first publish)
        
        A reference run was scored when the skill was first published. The setup:
        
        - Model: Claude Sonnet 4.6 (`sonnet` in the Agent / SDK)
        - Trials per cell: **N=1** (one trial per fixture × condition)
        - Scoring: manual, against the 7-item rubric in [`rubric.md`](./rubric.md)
        - Two passes on the treatment side: an initial run, then a second run after three targeted edits to `SKILL.md` driven by failure patterns from the first.
        
        Each cell is a 7-item score (0-7).
        
        | Fixture | Control | Treatment v1 | Treatment v2 (after SKILL.md edits) |
        |---|---:|---:|---:|
        | 01 OOM cascade | 5 | 7 | **7** |
        | 02 DNS resolution failure | 5 | 7 | **7** |
        | 03 Cascading-failure retry storm | 3 | 6 | **7** |
        | 04 Deploy-correlator (serialization) | 5 | 7 | **7** |
        | 05 Outside paths (third-party 429) | 2 | 4 | **7** |
        | 06 Ambiguous T0 (slow-burn leak) | 4 | 6 | **7** |
        | 07 Bundle blast-radius (6-PR train) | 5 | 5 | **7** |
        | 08 Confirmation bias (deploy + RBAC) | 4 | 6 | **7** |
        | 09 Zero changes (cert expiry) | 5 | 7 | **7** |
        | 10 Multi-region asymmetry | 5 | 6 | **7** |
        | 11 Capacity-bound organic growth | 5 | 7 | **7** |
        | **Aggregate mean** | **4.36** | **6.18** | **7.00** |
        
        **Lift**:
        - Treatment v1 over control: **+1.82 / 7 (+26%)**
        - Treatment v2 (final SKILL.md) over control: **+2.64 / 7 (+38%)**
        
        Treatment beats control on every fixture for both v1 and v2.
        
        ### What the v1 → v2 jump tells us
        
        The first treatment run surfaced three failure patterns that drove targeted edits to `SKILL.md`:
        
        | Failure pattern | Affected fixtures | Edit |
        |---|---|---|
        | T0 anchoring drift (agents picked first-error-in-logs instead of alert fire time) | 03, 05, 07, 10 | Step 1 now lists an explicit priority order: alert time > customer report > earliest unambiguous signal. |
        | Outside-reference-paths classification did not force escalation | 05, 07, 08 | Step 3 + step 6 now hard-constrain "outside reference paths → top action is 'escalate to a human'". |
        | Ambiguous T0 did not force "widen window first" before any revert | 06 | Step 1 + step 6 now mandate "re-run with widened window" as the first mitigation when T0 is ambiguous. |
        
        The edits are additive (no breaking changes to the methodology shape) and produce a clean v2 score across all 11 fixtures.
        
        ### Caveats on these specific numbers
        
        - **N=1 per cell** is directional only. A full run (`--trials 5`) would surface variance and likely shift individual cells by ±1.
        - **Manual scoring** has rater bias. Two scoring edge cases were generous to v2: fixture 03 (the agent recommended "throttle retries" which is functionally circuit-breaker but not labeled as such) and fixture 11 (the agent escalated per the new methodology, but the prior `incidents.py` expected `scale` directly). Both are defensible 1s but a stricter judge could mark them 0, dropping v2 aggregate to ~6.8 / 7.
        - **Ceiling effect**. 7/7 across 11 fixtures may signal a rubric that lacks the resolution to differentiate further. A finer-grained rubric (0-3 per item rather than 0-1) would expose more nuance.
        - **Sonnet only**. Opus may push control scores higher (more careful prose-following without methodology guidance), which would reduce the absolute lift. Haiku may push both lower. Re-running with the model used in production is the honest comparison.
        
        These caveats are the reason this reference run is a **directional signal**, not a publishable headline number. To get a publishable number, run `run_eval.py --trials 5` with LLM-as-judge scoring.
        
        ## What the eval does
        
        For each fixture, the script runs N trials in two conditions:
        
        - **Control**: agent is given the telemetry plus a generic prompt ("Investigate this incident. Here is the telemetry. Produce a timeline, root cause hypothesis, blast radius, and mitigation."). The agent uses whatever methodology it brings from its training.
        - **Treatment**: agent is given the same telemetry plus the full `SKILL.md` as the methodology to follow.
        
        Each agent output is then graded by an LLM judge against the 7-item rubric in [`rubric.md`](./rubric.md). The judge is given the known-good answer (generated by the deterministic reference implementation in `tests/_methodology.py`) so the grading is anchored to a ground truth, not the judge's own opinion.
        
        ## Interpreting the results
        
        The summary table shows:
        
        ```
        Fixture                                          Control mean   Treatment mean     Lift  C-std  T-std
        ----------------------------------------------------------------------------------------------------
        01-oom-cascade                                          4.20             6.40    +2.20   0.84   0.55
        03-cascading-failure-retry-storm                        3.40             6.20    +2.80   1.14   0.45
        ...
        
        Aggregate lift: +1.8/7 across 11 fixtures
          Positive lift: 10, Zero: 1, Negative: 0
          Verdict: Skill is clearly valuable
        ```
        
        - **Per-fixture lift**: how much SKILL.md helps on each scenario. Some scenarios (escalation cases, e.g. fixtures 05-08) are likely to show big lifts because the skill encodes explicit guards the cold agent doesn't know about. Others (canonical OOM with a clear deploy) may show smaller lifts because the cold agent can solve them with general SRE knowledge.
        - **Aggregate lift**: the mean across all fixtures. The verdict thresholds are heuristic and stated explicitly in `run_eval.py`; treat them as suggestions, not hard rules.
        - **Standard deviation per cell**: LLMs are stochastic. A high stdev with positive mean lift still means the skill helps on average; a high stdev with mean lift near zero means the skill is not reliably adding value.
        
        ## When to re-run this
        
        - After any non-trivial edit to `SKILL.md`. The methodology prose is load-bearing; an edit can subtly change how an agent follows it. Re-run with at least N=3 to catch regressions.
        - After adding a new worked example. The new fixture exercises a scenario the skill should handle; if the lift on the new fixture is near zero, the SKILL.md probably needs to be updated to cover that path.
        - Before submitting the skill to a marketplace (Anthropic / Cursor / Cline). A documented lift is a contributor-trust signal.
        
        ## Cost and time
        
        | Setting | Calls | Time (Sonnet) | Cost (Sonnet) |
        |---|---|---|---|
        | Smoke (3 fixtures, 1 trial) | 12 | ~3 min | <$1 |
        | Standard (11 fixtures, 3 trials) | 132 | ~20 min | ~$5-10 |
        | Full (11 fixtures, 5 trials) | 220 | ~35 min | ~$10-20 |
        | Full + Opus | 220 | ~50 min | ~$30-60 |
        
        Model selection: Sonnet is the recommended default. Opus produces higher-quality agent outputs but the lift signal is what matters, and Sonnet captures it cleanly at lower cost. Override via `EVAL_AGENT_MODEL` / `EVAL_JUDGE_MODEL` env vars or `--agent-model` / `--judge-model` CLI flags.
        
        ## What the eval does NOT measure
        
        - **Narrative quality.** A correct classification dressed up in flowery prose scores the same as a correct one stated tersely.
        - **Speed.** No wall-clock measurement (both conditions get the same budget).
        - **Cost-per-investigation.** No token accounting. Trivial to add as an extension.
        - **Real-world generalization.** The 11 fixtures are constructed, not pulled from production incidents. A skill that scores high here can still fail on a novel real incident; the replay corpus is a regression guard, not a validity proof.
        
        ## Files
        
        | File | Purpose |
        |---|---|
        | `run_eval.py` | The runner. Calls the Anthropic API in both conditions, calls the judge, aggregates. |
        | `incidents.py` | Per-fixture incident contexts and expected answers (from the deterministic reference impl). Source of truth for the judge. |
        | `rubric.md` | The 7-item rubric, with one sentence per item explaining the pass criterion. |
        | `judge_prompt.md` | The prompt template the judge model sees. Useful if you want to swap in a different judge implementation. |
        
        ## Adding a fixture to the eval
        
        When you add a new worked example under `examples/` and a new replay test under `tests/`:
        
        1. Add an entry to `INCIDENTS` in `incidents.py` with the same shape as the existing entries.
        2. The `expected_*` fields should match what `tests/_methodology.py` produces against the fixture (the replay test asserts on these).
        3. Re-run the eval and verify the new fixture appears in the summary table with a sensible per-fixture lift.
        
        ## Limitations and honest framing
        
        - **The judge is itself an LLM.** It can be wrong. Spot-check 5-10 random graded outputs the first time you run the eval to calibrate your trust.
        - **The known-good answer is generated by the deterministic reference impl.** If the reference impl has a methodology bug, the judge propagates it. The replay tests catch many such bugs, but not all.
        - **Lift is not a value claim by itself.** A skill can show high lift on the eval and still be unhelpful in practice if the fixtures are unrepresentative. Treat the eval as a regression guard plus a credibility signal, not as proof of operational value.
        
      • rubric.md 3.1 KB
        # Rubric: kubectl-investigator skill ablation
        
        The rubric grades an agent's incident investigation output against the seven methodology steps. Each item is binary (0 or 1). A perfect investigation scores 7. The same rubric is applied to both the control condition (agent invoked with no methodology guidance) and the treatment condition (agent invoked with `SKILL.md` loaded), so the delta between conditions measures the value the skill adds.
        
        The expected answers for each fixture are generated by `tests/_methodology.py`, the deterministic reference implementation. The agent under test is graded against those known-good outputs.
        
        ## The 7 items
        
        | # | Item | Pass criterion | Source of truth |
        |---|---|---|---|
        | 1 | **T0 anchor** | Agent picks T0 within ±2 minutes of the alert fire time stated in the prompt. | The alert time embedded in the incident prompt. |
        | 2 | **Change surface** | Agent correctly enumerates the changes in the window (count and kind). "Zero changes" is a valid answer when correct. | `bisect_change_surface()` output on the fixture. |
        | 3 | **Classification** | Agent classifies the failure into the correct reference path (or correctly escalates to outside-reference-paths). | `classify()` output on the fixture. |
        | 4 | **Independent signals** | Agent cites at least 3 signals drawn from independent source categories (logs, metrics, traces, change events, etc.). Two signals from the same category count as one. | `confirm_signals()` output (count of distinct sources >=3). |
        | 5 | **Blast radius** | Agent quantifies blast radius across users, surfaces, and business impact. Partial credit available but the binary score requires all three. | Agent narrative must address users + surfaces + business impact. |
        | 6 | **Mitigation ranking** | Agent's TOP recommended action matches the expected action: revert for code-deploy / infra-change / DNS / OOM-with-deploy; circuit_breaker for pure cascade; scale_resource for capacity-bound; escalate for outside-reference-paths. | `propose_mitigation()` first action (or escalation) on the fixture. |
        | 7 | **Handoff** | Agent produces a structured handoff including timeline, ranked hypothesis(es), recommended mitigation, and open questions. | Structural check: all four components present in the output. |
        
        ## Scoring
        
        - Per (fixture, condition, trial): score = sum of 7 items, range [0, 7].
        - Per (fixture, condition): mean and stdev across trials.
        - **Lift** = mean(treatment) − mean(control). Range [-7, +7]. Positive = skill helps. Near zero = skill is decorative. Negative = skill is hurting.
        
        The eval reports per-fixture lift and aggregate lift. A skill is "valuable" when the aggregate lift is consistently positive across most fixtures.
        
        ## What the rubric does not measure
        
        - **Narrative quality.** A correct classification dressed up in flowery prose scores the same as a correct classification stated tersely. The rubric is methodology-driven, not stylistic.
        - **Speed.** No timing measurement. Both conditions get the same wall-clock budget per trial.
        - **Cost.** No token accounting. The runner can add it as a side measurement if desired.
        
        These can be added as extension rubric items in a follow-up.
        
      • run_eval.py 15 KB
        """
        Ablation eval for the kubectl-investigator skill.
        
        For each fixture, runs N trials in each of two conditions:
        - Control: the agent is given the telemetry and a generic "investigate this incident" prompt.
        - Treatment: the agent is given the same telemetry plus SKILL.md as the methodology to follow.
        
        Each agent output is graded against a 7-item rubric (rubric.md) by an LLM judge.
        The judge uses the deterministic reference implementation's outputs (from _methodology.py)
        as the source of truth.
        
        Final report: per-fixture mean score (control vs treatment), lift (= treatment - control),
        stdev across trials, and a "skill is valuable when lift is consistently positive" verdict.
        
        Requirements:
        - ANTHROPIC_API_KEY environment variable.
        - `pip install anthropic` (the only non-stdlib dependency in the repo; isolated to tests/eval/).
        
        Usage:
            python tests/eval/run_eval.py --trials 5
            python tests/eval/run_eval.py --trials 1 --fixtures 01,03,05  # smoke test
        
        Cost note: with 11 fixtures, 2 conditions, 5 trials, plus the judge call per output,
        the eval makes ~110 agent calls + ~110 judge calls = ~220 LLM calls. Expect $20-50
        depending on model (Sonnet recommended for cost; Opus for highest agent quality).
        """
        
        from __future__ import annotations
        
        import argparse
        import json
        import os
        import statistics
        import sys
        import time
        from pathlib import Path
        
        try:
            from anthropic import Anthropic
        except ImportError:
            print("ERROR: anthropic SDK not installed. Run: pip install anthropic", file=sys.stderr)
            sys.exit(1)
        
        sys.path.insert(0, str(Path(__file__).parent))
        from incidents import INCIDENTS, load_fixture_text  # noqa: E402
        
        REPO_ROOT = Path(__file__).resolve().parent.parent.parent
        SKILL_MD = REPO_ROOT / "SKILL.md"
        
        DEFAULT_AGENT_MODEL = os.environ.get("EVAL_AGENT_MODEL", "claude-sonnet-4-6")
        DEFAULT_JUDGE_MODEL = os.environ.get("EVAL_JUDGE_MODEL", "claude-sonnet-4-6")
        MAX_TOKENS = 4096
        
        
        def build_control_prompt(incident: dict) -> str:
            fixture_text = load_fixture_text(incident["fixture_dir"])
            return f"""You are an SRE investigating an incident. Here is what you know:
        
        - Service: {incident['service']}
        - Alert fired at: {incident['alert_time']} ({incident['alert_message']})
        - Investigation triggered at: {incident['tnow']}
        
        All telemetry for the affected service:
        
        {fixture_text}
        
        Investigate the incident. Produce: a timeline, root-cause hypothesis with supporting evidence, blast-radius estimate, and recommended mitigation. Be specific about what you would do first."""
        
        
        def build_treatment_prompt(incident: dict, skill_md_text: str) -> str:
            return f"""You are an SRE investigating an incident, following the methodology below exactly.
        
        METHODOLOGY (SKILL.md):
        
        {skill_md_text}
        
        INCIDENT CONTEXT:
        
        - Service: {incident['service']}
        - Alert fired at: {incident['alert_time']} ({incident['alert_message']})
        - Investigation triggered at: {incident['tnow']}
        
        TELEMETRY:
        
        {load_fixture_text(incident['fixture_dir'])}
        
        Apply the methodology end-to-end. Produce the structured output the methodology's "Output format" section prescribes (seven sections)."""
        
        
        JUDGE_SYSTEM = """You are an expert SRE evaluator grading an incident investigation against a 7-item rubric. Each item is binary: 1 (pass) or 0 (fail). Be strict but fair; no partial credit.
        
        You will be given:
        1. A known-good answer generated by a deterministic reference implementation of the methodology.
        2. The agent's investigation output.
        3. The 7 rubric items.
        
        Return JSON only (no prose), with this exact schema:
        
        {
          "scores": [<int>, <int>, <int>, <int>, <int>, <int>, <int>],
          "notes": ["<one sentence>", ...]
        }"""
        
        
        def build_judge_prompt(incident: dict, agent_output: str) -> str:
            return f"""INCIDENT: {incident['id']}
        SERVICE: {incident['service']}
        
        KNOWN-GOOD ANSWER (from deterministic methodology):
        - T0: {incident['expected_t0']}
        - Change surface: {incident['expected_change_count']} change(s) in window - {incident['expected_change_summary']}
        - Classification: {incident['expected_path']}
        - Top mitigation: {incident['expected_top_mitigation']}
        - Should escalate to human: {incident['expected_escalate']}
        
        AGENT INVESTIGATION OUTPUT:
        {agent_output}
        
        RUBRIC:
        1. T0 anchor: agent picks T0 within +/-2 minutes of {incident['expected_t0']}.
        2. Change surface: agent correctly enumerates {incident['expected_change_count']} change(s) in window ({incident['expected_change_summary']}).
        3. Classification: agent classifies the failure as {incident['expected_path']}.
        4. Independent signals: agent cites at least 3 signals from distinct source categories (logs, metrics, traces, change events, orchestrator events). Two signals from the same category count as one.
        5. Blast radius: agent quantifies users affected + surfaces affected + business impact (all three required for pass).
        6. Mitigation: agent's TOP recommended action matches {incident['expected_top_mitigation']}.
        7. Handoff: agent produces a structured handoff including timeline, ranked hypothesis(es), recommended mitigation, and open questions (all four required for pass).
        
        Score each item as 1 (pass) or 0 (fail). Return JSON only."""
        
        
        RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 529}
        MAX_RETRIES = 6
        
        
        def _with_retries(fn, *args, **kwargs):
            """Call fn with exponential backoff on transient API errors (429/5xx/529/overloaded).
        
            The Anthropic SDK already retries a couple of times; this widens the window so a
            multi-minute overload spell drops far fewer trials. Re-raises on non-retryable
            errors or once retries are exhausted.
        
            Returns (result, call_seconds) where call_seconds is the wall-time of the SUCCESSFUL
            attempt only - backoff sleeps and failed attempts are excluded, so duration metrics
            reflect real investigation latency, not how overloaded the API happened to be.
            """
            delay = 2.0
            last_exc = None
            for attempt in range(MAX_RETRIES):
                try:
                    t_call = time.time()
                    return fn(*args, **kwargs), time.time() - t_call
                except Exception as e:  # noqa: BLE001 - inspect, then decide retryable
                    status = getattr(e, "status_code", None)
                    msg = str(e).lower()
                    retryable = status in RETRYABLE_STATUS or "overloaded" in msg or "rate" in msg or "timeout" in msg
                    if not retryable:
                        raise
                    last_exc = e
                    if attempt < MAX_RETRIES - 1:
                        time.sleep(delay)
                        delay = min(delay * 2, 60.0)
            raise last_exc
        
        
        def run_agent(client: Anthropic, model: str, prompt: str) -> tuple[str, float]:
            """Returns (agent_output_text, investigation_seconds). Seconds excludes retry backoff."""
            def _call():
                return client.messages.create(
                    model=model,
                    max_tokens=MAX_TOKENS,
                    messages=[{"role": "user", "content": prompt}],
                )
            resp, call_s = _with_retries(_call)
            return "".join(block.text for block in resp.content if block.type == "text"), call_s
        
        
        def run_judge(client: Anthropic, model: str, incident: dict, agent_output: str) -> dict:
            def _call():
                return client.messages.create(
                    model=model,
                    max_tokens=1024,
                    system=JUDGE_SYSTEM,
                    messages=[{"role": "user", "content": build_judge_prompt(incident, agent_output)}],
                )
            resp, _ = _with_retries(_call)
            raw = "".join(block.text for block in resp.content if block.type == "text")
            # Strip code fences if the judge wrapped JSON in markdown.
            raw = raw.strip()
            if raw.startswith("```"):
                raw = raw.split("```", 2)[1]
                if raw.startswith("json"):
                    raw = raw[4:]
                raw = raw.rsplit("```", 1)[0]
            return json.loads(raw.strip())
        
        
        def main() -> int:
            parser = argparse.ArgumentParser()
            parser.add_argument("--trials", type=int, default=5, help="Trials per (fixture, condition) cell")
            parser.add_argument("--fixtures", default="", help="Comma-separated fixture IDs (prefix match); empty = all")
            parser.add_argument("--agent-model", default=DEFAULT_AGENT_MODEL)
            parser.add_argument("--judge-model", default=DEFAULT_JUDGE_MODEL)
            parser.add_argument("--output", default="eval_results.json", help="Where to write the raw results")
            parser.add_argument("--fresh", action="store_true", help="Ignore an existing results file and start clean (default: resume/fill gaps)")
            args = parser.parse_args()
        
            if "ANTHROPIC_API_KEY" not in os.environ:
                print("ERROR: ANTHROPIC_API_KEY not set", file=sys.stderr)
                return 1
        
            client = Anthropic()
            skill_md_text = SKILL_MD.read_text()
        
            fixtures_to_run = INCIDENTS
            if args.fixtures:
                filters = [f.strip() for f in args.fixtures.split(",")]
                fixtures_to_run = [i for i in INCIDENTS if any(i["id"].startswith(f) for f in filters)]
        
            print(f"Running {len(fixtures_to_run)} fixtures x 2 conditions x {args.trials} trials = {len(fixtures_to_run) * 2 * args.trials} agent calls")
            print(f"Agent model: {args.agent_model}, Judge model: {args.judge_model}\n")
        
            # Resume: reload any completed trials from a prior run so a re-run fills ONLY the
            # gaps (e.g. trials dropped to a transient overload), never redoing finished work.
            # Pass --fresh to ignore an existing results file and start clean.
            results: list[dict] = []
            completed: set[tuple[str, str, int]] = set()
            out_path = Path(args.output)
            if out_path.exists() and not args.fresh:
                try:
                    results = json.loads(out_path.read_text())
                    completed = {(r["incident"], r["condition"], r["trial"]) for r in results}
                    print(f"Resuming from {args.output}: {len(completed)} trials already complete; filling gaps only.\n")
                except (json.JSONDecodeError, KeyError, OSError):
                    results, completed = [], set()
        
            for incident in fixtures_to_run:
                for condition in ("control", "treatment"):
                    for trial in range(args.trials):
                        if (incident["id"], condition, trial) in completed:
                            continue  # already have this cell from a prior run
                        t_start = time.time()
                        prompt = build_control_prompt(incident) if condition == "control" else build_treatment_prompt(incident, skill_md_text)
                        try:
                            agent_output, agent_s = run_agent(client, args.agent_model, prompt)  # agent_s excludes retry backoff
                            judge_result = run_judge(client, args.judge_model, incident, agent_output)
                            score = sum(judge_result["scores"])
                        except Exception as e:
                            print(f"  ERROR on {incident['id']} {condition} trial {trial}: {e}", file=sys.stderr)
                            continue
                        elapsed = time.time() - t_start  # agent + judge, for cost/wall-clock accounting
                        results.append({
                            "incident": incident["id"],
                            "condition": condition,
                            "trial": trial,
                            "score": score,
                            "scores_by_item": judge_result["scores"],
                            "notes": judge_result.get("notes", []),
                            "agent_output": agent_output,
                            "agent_chars": len(agent_output),
                            "agent_s": round(agent_s, 1),
                            "elapsed_s": round(elapsed, 1),
                        })
                        # Crash-safe: persist after every trial via atomic temp+rename so an
                        # overload-induced death never throws away completed work.
                        tmp = Path(str(args.output) + ".tmp")
                        tmp.write_text(json.dumps(results, indent=2))
                        tmp.replace(args.output)
                        print(f"  {incident['id']} | {condition:9s} | trial {trial} | score {score}/7 | investigate {agent_s:.0f}s", flush=True)
        
            Path(args.output).write_text(json.dumps(results, indent=2))
            print(f"\nRaw results: {args.output}\n")
            print_summary(results)
            return 0
        
        
        def print_summary(results: list[dict]) -> None:
            by_cell: dict[tuple[str, str], list[int]] = {}
            dur_cell: dict[tuple[str, str], list[float]] = {}
            for r in results:
                by_cell.setdefault((r["incident"], r["condition"]), []).append(r["score"])
                dur_cell.setdefault((r["incident"], r["condition"]), []).append(r.get("agent_s", r.get("elapsed_s", 0.0)))
        
            # --- Quality table ---
            print(f"{'Fixture':<48} {'Control mean':>14} {'Treatment mean':>16} {'Lift':>8} {'C-std':>7} {'T-std':>7}")
            print("-" * 100)
            lifts = []
            for incident in INCIDENTS:
                control_scores = by_cell.get((incident["id"], "control"), [])
                treatment_scores = by_cell.get((incident["id"], "treatment"), [])
                if not control_scores or not treatment_scores:
                    continue
                c_mean = statistics.mean(control_scores)
                t_mean = statistics.mean(treatment_scores)
                lift = t_mean - c_mean
                lifts.append(lift)
                c_std = statistics.stdev(control_scores) if len(control_scores) > 1 else 0.0
                t_std = statistics.stdev(treatment_scores) if len(treatment_scores) > 1 else 0.0
                print(f"{incident['id']:<48} {c_mean:>14.2f} {t_mean:>16.2f} {lift:>+8.2f} {c_std:>7.2f} {t_std:>7.2f}")
            print("-" * 100)
            if lifts:
                aggregate = statistics.mean(lifts)
                positive = sum(1 for l in lifts if l > 0)
                zero = sum(1 for l in lifts if l == 0)
                negative = sum(1 for l in lifts if l < 0)
                print(f"\nAggregate lift: {aggregate:+.2f}/7 across {len(lifts)} fixtures")
                print(f"  Positive lift: {positive}, Zero: {zero}, Negative: {negative}")
                verdict = (
                    "Skill is clearly valuable" if aggregate >= 1.0 and positive >= 2 * negative
                    else "Skill provides marginal lift" if aggregate >= 0.3
                    else "Skill is not clearly adding value; investigate why before shipping"
                )
                print(f"  Verdict: {verdict}")
        
            # --- Duration table (investigation = agent call only, judge excluded) ---
            print(f"\n{'Fixture':<48} {'Control s':>12} {'Treatment s':>14} {'Delta s':>9} {'Ratio':>7}")
            print("-" * 100)
            c_durs_all, t_durs_all = [], []
            for incident in INCIDENTS:
                c_durs = dur_cell.get((incident["id"], "control"), [])
                t_durs = dur_cell.get((incident["id"], "treatment"), [])
                if not c_durs or not t_durs:
                    continue
                c_d = statistics.mean(c_durs)
                t_d = statistics.mean(t_durs)
                c_durs_all.extend(c_durs)
                t_durs_all.extend(t_durs)
                ratio = (t_d / c_d) if c_d else 0.0
                print(f"{incident['id']:<48} {c_d:>12.1f} {t_d:>14.1f} {t_d - c_d:>+9.1f} {ratio:>6.2f}x")
            print("-" * 100)
            if c_durs_all and t_durs_all:
                c_mean_d = statistics.mean(c_durs_all)
                t_mean_d = statistics.mean(t_durs_all)
                ratio = (t_mean_d / c_mean_d) if c_mean_d else 0.0
                print(f"\nMean investigation duration: control {c_mean_d:.1f}s vs treatment {t_mean_d:.1f}s "
                      f"({t_mean_d - c_mean_d:+.1f}s, {ratio:.2f}x)")
                print("  Note: agent call only (judge excluded). Treatment loads the full SKILL.md and emits")
                print("  seven structured sections, so it is expected to run longer; that is the latency cost of the lift.")
        
        
        if __name__ == "__main__":
            sys.exit(main())
        
      • run_until_pass.sh 2.1 KB
        #!/usr/bin/env bash
        # Run the ablation eval fixture-by-fixture, retrying each fixture until BOTH arms
        # (control + treatment) have all TRIALS trials. Leans on run_eval.py's resume
        # (fills only missing trials) and per-call backoff. Safe to interrupt and re-run.
        #
        # Usage: bash tests/eval/run_until_pass.sh [TRIALS] [MAX_ATTEMPTS_PER_FIXTURE]
        set -u
        cd "$(dirname "$0")/../.."   # -> skills/kubectl-investigator
        TRIALS="${1:-1}"
        MAX_ATTEMPTS="${2:-12}"
        OUT="tests/eval/eval_results.json"
        
        FIXTURES=(
          01-oom-cascade
          02-dns-resolution-failure
          03-cascading-failure-retry-storm
          04-deploy-correlator-serialization
          05-outside-reference-paths-third-party-rate-limit
          06-ambiguous-t0-slow-burn
          07-blast-radius-asymmetric-revert
          08-deploy-correlator-confirmation-bias
          09-zero-changes-external-cert-expiry
          10-multi-region-asymmetry
          11-capacity-bound-organic-growth
        )
        
        complete() {  # exit 0 if fixture $1 has >=TRIALS in each arm
          python - "$1" "$TRIALS" "$OUT" <<'PY'
        import json, sys
        fx, T, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
        try:
            d = json.load(open(out))
        except Exception:
            sys.exit(1)
        c = sum(1 for r in d if r["incident"] == fx and r["condition"] == "control")
        t = sum(1 for r in d if r["incident"] == fx and r["condition"] == "treatment")
        print(f">>> {fx}: control {c}/{T}, treatment {t}/{T}")
        sys.exit(0 if c >= T and t >= T else 1)
        PY
        }
        
        echo "=== run_until_pass: TRIALS=$TRIALS, $((${#FIXTURES[@]})) fixtures ==="
        for fx in "${FIXTURES[@]}"; do
          echo "----- fixture $fx -----"
          for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
            python -u tests/eval/run_eval.py --fixtures "$fx" --trials "$TRIALS" --output "$OUT" 2>&1 \
              | grep -E 'investigate|ERROR on' || true
            if complete "$fx"; then
              echo "    $fx COMPLETE (attempt $attempt)"
              break
            fi
            echo "    $fx incomplete after attempt $attempt; retrying..."
            sleep 5
          done
          complete "$fx" >/dev/null || echo "!!! $fx still incomplete after $MAX_ATTEMPTS attempts; moving on"
        done
        
        echo "=== ALL FIXTURES PROCESSED — final summary ==="
        python tests/eval/run_eval.py --trials "$TRIALS" --output "$OUT" 2>&1 | sed -n '/^Fixture/,$p'
        
    • README.md 4.3 KB
      # Replay tests for `kubectl-investigator`
      
      Stdlib-only Python tests that exercise the methodology in [`../SKILL.md`](../SKILL.md) against committed fixtures. No external credentials required.
      
      ## Running the tests
      
      From the skill directory (`skills/kubectl-investigator/`):
      
      ```bash
      for t in tests/replay_*.py; do python "$t" || exit 1; done
      ```
      
      Each test prints `PASS` or `FAIL` and exits with the appropriate code. The current suite has 11 tests covering the four reference paths, the FAILURE_MODES escalation rules (M1, M2, M3, M4), and edge cases (zero changes in window, multi-region asymmetry, capacity saturation), totalling 99 assertions. Wire them into CI as plain `python` invocations.
      
      ## What the tests assert
      
      Each replay test loads the fixtures for one worked example, runs the reference methodology (`_methodology.py`) end-to-end against them, and asserts:
      
      - The investigation window is anchored correctly (step 1).
      - The change surface contains the expected change(s) (step 2).
      - The failure is classified into the right reference path (step 3).
      - At least three independent signal sources are confirmed (step 4).
      - The blast-radius numbers fall in the expected range (step 5).
      - The recommended mitigation is correct, with the right action ordering (step 6).
      - The handoff payload is well-formed and the escalation flag matches expectations (step 7).
      
      A test fails when the methodology regresses on any of these. Treat a failed replay test as a regression in `SKILL.md` or in the reference implementation, not a test bug.
      
      ## Fixture schema
      
      Each example has its own fixture directory under `../fixtures/<example-slug>/`. Files are committed JSON / JSONL with no external dependencies.
      
      | File | Format | Purpose |
      |---|---|---|
      | `deploys.json` | JSON | Single object with `deploys`, `infra_changes` (cluster / node-pool / HPA / manifest edits), `rbac_changes` (Role / RoleBinding / ServiceAccount edits), `feature_flags`, `scheduled_jobs` (CronJobs) arrays. Each event has a timestamp field (`deployed_at`, `changed_at`, `flipped_at`, `ran_at`) and a `diff_summary` string. |
      | `pod_events.jsonl` | JSONL (optional) | Kubernetes pod / kubelet events. One JSON object per line with `t`, `pod`, `reason` (e.g. `OOMKilled`, `BackOff`, `Unhealthy`), `message`. Skip if the example is not pod-level. |
      | `metrics.json` | JSON | Time-series snapshot. Fields: `service`, `memory_limit_bytes`, `samples[]`. Each sample has `t`, `rss_bytes_p95`, `error_rate_pct`, `request_rate_rps`, `gateway_retry_rate_rps`, and any path-specific counter (e.g. `dns_resolver_errors_rps`). |
      | `logs.jsonl` | JSONL (optional) | Application or system logs. One JSON object per line with `t`, `level`, `service`, `msg`, and any path-specific fields. |
      | `traces.jsonl` | JSONL (optional) | Distributed traces. One JSON object per line with `t`, `trace_id`, `request_id`, `spans[]`, `outcome`, `retries`. |
      
      The reference implementation (`_methodology.py`) gracefully handles missing optional files (returns empty lists).
      
      ## Adding a new replay test
      
      When you contribute a new worked example to the skill:
      
      1. Drop fixtures under `../fixtures/<example-slug>/` following the schema above.
      2. Add `replay_NN_<example-slug>.py` in this directory, modeled on the existing two.
      3. Pick assertions that cover the seven methodology steps. Existing tests range from 6 to 13 assertions. New tests typically want 7 to 15.
      4. Run locally, commit, and reference the test in the example's markdown narrative.
      
      A new test that does not exercise at least one independent signal source the existing tests do not exercise will fail review. The point of the replay corpus is breadth.
      
      ## Why stdlib only
      
      Skills get adopted when they run anywhere with zero setup. A `pip install` is an adoption tax. The reference implementation uses only `datetime`, `json`, `pathlib`, `dataclasses`, and `typing`. If a future test requires a third-party dependency (e.g. `pytest`), that's a signal the methodology is leaking implementation detail.
      
      ## Future: agent-shaped tests
      
      The reference implementation in `_methodology.py` is a deterministic stand-in. A natural follow-up is to run the same fixtures through an actual LLM agent loaded with `SKILL.md` and assert the agent produces the same classification + mitigation. That work is out of scope for the first reference example; contributions welcome.
      
    • replay_01_oom_cascade.py 3.6 KB
      """
      Replay test for examples/01-oom-cascade.md.
      
      Runs the reference methodology in `_methodology.py` against the OOM cascade
      fixtures and asserts the methodology produces the expected classification,
      mitigation, and handoff payload.
      
      Stdlib only. Run with: `python tests/replay_01_oom_cascade.py`.
      Exits 0 on success, 1 on any assertion failure.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "01-oom-cascade"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-03-12T14:32:00Z",
              tnow_iso="2026-03-12T14:36:00Z",
              failing_surface_hints=["webhook", "buffer"],
          )
      
          assertions = [
              # Step 1: window anchored correctly with the 15-minute lead-in.
              (inv.window[0].isoformat() == "2026-03-12T14:17:00+00:00", f"window start expected 14:17:00Z, got {inv.window[0].isoformat()}"),
      
              # Step 2: change surface contains the 14:18 deploy and nothing else.
              (len(inv.change_surface) == 1, f"expected 1 change in window, got {len(inv.change_surface)}"),
              (inv.change_surface[0]["kind"] == "deploy", f"expected deploy, got {inv.change_surface[0]['kind']}"),
              (inv.change_surface[0]["version"] == "v4.18.0", f"expected v4.18.0, got {inv.change_surface[0]['version']}"),
      
              # Step 3: classified as OOM (not cascading-failure, not deploy-correlator).
              (inv.classified_path == "OOM", f"expected OOM, got {inv.classified_path}"),
              (any("OOMKilled" in e for e in inv.classification_evidence), "expected OOMKilled in classification evidence"),
              (any("webhook" in e.lower() or "buffer" in e.lower() for e in inv.classification_evidence), "expected webhook/buffer surface match in evidence"),
      
              # Step 4: at least three independent signal sources.
              (len({s["source"] for s in inv.confirming_signals}) >= 3, f"expected >=3 independent signal sources, got {len({s['source'] for s in inv.confirming_signals})}"),
      
              # Step 5: blast radius reflects payment-traffic failure (>10% error rate at peak).
              (inv.blast_radius["error_rate_peak_pct"] >= 10, f"expected error_rate_peak_pct >= 10, got {inv.blast_radius['error_rate_peak_pct']}"),
      
              # Step 6: mitigation ranked, revert first.
              (inv.mitigation_ranked[0]["action"] == "revert", f"expected revert as first mitigation, got {inv.mitigation_ranked[0]['action']}"),
              (any("scale_resource" == m["action"] for m in inv.mitigation_ranked), "expected scale_resource as a fallback mitigation"),
      
              # Step 7: handoff payload includes the right classified path and is not escalated.
              (inv.handoff["classified_path"] == "OOM", "handoff payload should carry OOM classification"),
              (inv.escalate_to_human is False, f"OOM-with-3-signals should not escalate, got escalation_reasons={inv.escalation_reasons}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_01_oom_cascade")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_01_oom_cascade ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  recommended:     {inv.mitigation_ranked[0]['action']} -> {inv.mitigation_ranked[0]['target']}")
          print(f"  signals:         {len(inv.confirming_signals)} from {len({s['source'] for s in inv.confirming_signals})} sources")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_02_dns.py 3.3 KB
      """
      Replay test for examples/02-dns-resolution-failure.md.
      
      Runs the reference methodology against the DNS fixtures and asserts the
      methodology produces the expected classification, mitigation, and handoff.
      
      Stdlib only. Run with: `python tests/replay_02_dns.py`.
      Exits 0 on success, 1 on any assertion failure.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "02-dns-resolution-failure"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-04-08T09:47:00Z",
              tnow_iso="2026-04-08T09:53:00Z",
              failing_surface_hints=["coredns", ".internal"],
          )
      
          assertions = [
              # Step 1: window anchored correctly.
              (inv.window[0].isoformat() == "2026-04-08T09:32:00+00:00", f"window start expected 09:32:00Z, got {inv.window[0].isoformat()}"),
      
              # Step 2: change surface contains the CoreDNS ConfigMap change and no code deploys.
              (len(inv.change_surface) == 1, f"expected 1 change in window, got {len(inv.change_surface)}"),
              (inv.change_surface[0]["kind"] == "infra", f"expected infra change, got {inv.change_surface[0]['kind']}"),
              (inv.change_surface[0].get("resource") == "kube-system/coredns", f"expected coredns ConfigMap, got {inv.change_surface[0].get('resource')}"),
      
              # Step 3: classified as DNS (not OOM, not deploy-correlator).
              (inv.classified_path == "DNS", f"expected DNS, got {inv.classified_path}"),
              (any("DNS error log lines" in e for e in inv.classification_evidence), f"expected DNS log evidence, got {inv.classification_evidence}"),
      
              # Step 4: at least three independent signal sources.
              (len({s["source"] for s in inv.confirming_signals}) >= 3, f"expected >=3 independent signal sources, got {len({s['source'] for s in inv.confirming_signals})}"),
      
              # Step 5: blast radius reflects elevated but partial error rate (intermittent DNS).
              (1 <= inv.blast_radius["error_rate_peak_pct"] <= 10, f"expected partial error rate (1-10%), got {inv.blast_radius['error_rate_peak_pct']}"),
      
              # Step 6: mitigation ranked, revert first (ConfigMap revert is the path).
              (inv.mitigation_ranked[0]["action"] == "revert", f"expected revert as first mitigation, got {inv.mitigation_ranked[0]['action']}"),
      
              # Step 7: handoff includes DNS classification, no escalation needed.
              (inv.handoff["classified_path"] == "DNS", "handoff payload should carry DNS classification"),
              (inv.escalate_to_human is False, f"DNS-with-3-signals should not escalate, got escalation_reasons={inv.escalation_reasons}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_02_dns")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_02_dns ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  recommended:     {inv.mitigation_ranked[0]['action']} -> {inv.mitigation_ranked[0]['target']}")
          print(f"  signals:         {len(inv.confirming_signals)} from {len({s['source'] for s in inv.confirming_signals})} sources")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_03_cascade.py 3.2 KB
      """
      Replay test for examples/03-cascading-failure-retry-storm.md.
      
      Stdlib only. Run with: `python tests/replay_03_cascade.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "03-cascading-failure-retry-storm"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-03-20T11:08:00Z",
              tnow_iso="2026-03-20T11:15:00Z",
          )
      
          assertions = [
              (inv.window[0].isoformat() == "2026-03-20T10:53:00+00:00", f"window start expected 10:53:00Z, got {inv.window[0].isoformat()}"),
      
              # Step 2: zero changes in window.
              (len(inv.change_surface) == 0, f"expected 0 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: cascading-failure classification, triggered by upstream latency growth.
              (inv.classified_path == "cascading-failure", f"expected cascading-failure, got {inv.classified_path}"),
              (any("upstream" in e.lower() or "retry rate" in e.lower() for e in inv.classification_evidence), f"expected upstream-latency or retry-rate evidence, got {inv.classification_evidence}"),
      
              # Step 4: at least 3 independent signal sources (no change_audit possible since no changes).
              (len({s["source"] for s in inv.confirming_signals}) >= 3, f"expected >=3 independent signal sources, got {len({s['source'] for s in inv.confirming_signals})}"),
      
              # Step 5: blast radius reflects partial failure with high gateway retry pressure.
              (5 <= inv.blast_radius["error_rate_peak_pct"] <= 20, f"expected partial error rate (5-20%), got {inv.blast_radius['error_rate_peak_pct']}"),
      
              # Step 6: mitigation should NOT lead with revert (no change in window); circuit-breaker first.
              (inv.mitigation_ranked[0]["action"] == "circuit_breaker", f"expected circuit_breaker as first mitigation, got {inv.mitigation_ranked[0]['action']}"),
              (any(m["action"] == "traffic_shift" for m in inv.mitigation_ranked), "expected traffic_shift as a follow-up mitigation"),
              (not any(m["action"] == "revert" for m in inv.mitigation_ranked), "should NOT recommend revert when no change in window"),
      
              # Step 7: handoff payload reflects the classification, no escalation needed (3 signals confirmed).
              (inv.handoff["classified_path"] == "cascading-failure", "handoff payload should carry cascading-failure classification"),
              (inv.escalate_to_human is False, f"cascade-with-3-signals should not escalate, got escalation_reasons={inv.escalation_reasons}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_03_cascade")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_03_cascade ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  recommended:     {inv.mitigation_ranked[0]['action']} -> {inv.mitigation_ranked[0]['target']}")
          print(f"  signals:         {len(inv.confirming_signals)} from {len({s['source'] for s in inv.confirming_signals})} sources")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_04_deploy_correlator.py 3.3 KB
      """
      Replay test for examples/04-deploy-correlator-serialization.md.
      
      Stdlib only. Run with: `python tests/replay_04_deploy_correlator.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "04-deploy-correlator-serialization"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-02-15T13:25:00Z",
              tnow_iso="2026-02-15T13:30:00Z",
              failing_surface_hints=["cart", "serializer"],
          )
      
          assertions = [
              (inv.window[0].isoformat() == "2026-02-15T13:10:00+00:00", f"window start expected 13:10:00Z, got {inv.window[0].isoformat()}"),
      
              # Step 2: one change in window, the v6.4.0 deploy.
              (len(inv.change_surface) == 1, f"expected 1 change in window, got {len(inv.change_surface)}"),
              (inv.change_surface[0]["version"] == "v6.4.0", f"expected v6.4.0, got {inv.change_surface[0].get('version')}"),
      
              # Step 3: classified as deploy-correlator (NOT OOM, NOT cascade, NOT DNS).
              (inv.classified_path == "deploy-correlator", f"expected deploy-correlator, got {inv.classified_path}"),
              (any("cart" in e.lower() or "serializer" in e.lower() for e in inv.classification_evidence), f"expected cart/serializer surface match, got {inv.classification_evidence}"),
      
              # Step 4: at least 3 independent signal sources.
              (len({s["source"] for s in inv.confirming_signals}) >= 3, f"expected >=3 signal sources, got {len({s['source'] for s in inv.confirming_signals})}"),
      
              # Step 5: blast radius is partial, low single-digit error rate.
              (2 <= inv.blast_radius["error_rate_peak_pct"] <= 10, f"expected partial error rate (2-10%), got {inv.blast_radius['error_rate_peak_pct']}"),
      
              # Step 6: mitigation leads with revert.
              (inv.mitigation_ranked[0]["action"] == "revert", f"expected revert as first mitigation, got {inv.mitigation_ranked[0]['action']}"),
              (inv.mitigation_ranked[0].get("target", "").startswith("checkout-api"), f"expected revert target to be checkout-api, got {inv.mitigation_ranked[0].get('target')}"),
              # Should NOT propose circuit_breaker (that's cascade-specific).
              (not any(m["action"] == "circuit_breaker" for m in inv.mitigation_ranked), "should NOT recommend circuit_breaker for deploy-correlator"),
      
              # Step 7: handoff payload + no escalation.
              (inv.handoff["classified_path"] == "deploy-correlator", "handoff payload should carry deploy-correlator"),
              (inv.escalate_to_human is False, f"deploy-correlator with surface match should not escalate, got {inv.escalation_reasons}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_04_deploy_correlator")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_04_deploy_correlator ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  recommended:     {inv.mitigation_ranked[0]['action']} -> {inv.mitigation_ranked[0]['target']}")
          print(f"  signals:         {len(inv.confirming_signals)} from {len({s['source'] for s in inv.confirming_signals})} sources")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_05_outside_paths.py 2.4 KB
      """
      Replay test for examples/05-outside-reference-paths-third-party-rate-limit.md.
      
      Stdlib only. Run with: `python tests/replay_05_outside_paths.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "05-outside-reference-paths-third-party-rate-limit"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-05-04T16:44:00Z",
              tnow_iso="2026-05-04T16:50:00Z",
          )
      
          assertions = [
              (inv.window[0].isoformat() == "2026-05-04T16:29:00+00:00", f"window start expected 16:29:00Z, got {inv.window[0].isoformat()}"),
      
              # Step 2: zero changes in window.
              (len(inv.change_surface) == 0, f"expected 0 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: outside reference paths (no OOM, no DNS, no cascade signature, no deploy).
              (inv.classified_path == "outside-reference-paths", f"expected outside-reference-paths, got {inv.classified_path}"),
      
              # Step 6: no revert proposed (no change to revert).
              (not any(m["action"] == "revert" for m in inv.mitigation_ranked), "should NOT recommend revert when no change in window"),
      
              # Step 7: ESCALATE per M1.
              (inv.escalate_to_human is True, "outside-reference-paths must escalate per M1"),
              (any("M1" in r for r in inv.escalation_reasons), f"expected M1 escalation reason, got {inv.escalation_reasons}"),
      
              # Handoff should mark escalation explicitly.
              (inv.handoff["escalate_to_human"] is True, "handoff payload must carry escalate_to_human=True"),
              (inv.handoff["classified_path"] == "outside-reference-paths", "handoff classification must be outside-reference-paths"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_05_outside_paths")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_05_outside_paths ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  escalate:        {inv.escalate_to_human} ({', '.join(inv.escalation_reasons)})")
          print(f"  signals:         {len(inv.confirming_signals)} from {len({s['source'] for s in inv.confirming_signals})} sources")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_06_ambiguous_t0.py 2.3 KB
      """
      Replay test for examples/06-ambiguous-t0-slow-burn.md.
      
      Stdlib only. Run with: `python tests/replay_06_ambiguous_t0.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "06-ambiguous-t0-slow-burn"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-04-19T09:32:14Z",
              tnow_iso="2026-04-19T12:15:00Z",
              t0_ambiguous=True,
          )
      
          assertions = [
              # Step 1: window spans ~3 hours (T0 + 15min lead-in).
              ((inv.tnow - inv.window[0]).total_seconds() >= 9000, f"expected widened window >=150min, got {(inv.tnow - inv.window[0]).total_seconds() / 60:.0f}min"),
      
              # Step 2: zero changes in window (the actual causal deploy is 3 days outside).
              (len(inv.change_surface) == 0, f"expected 0 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: OOM classification on signature (slow trajectory).
              (inv.classified_path == "OOM", f"expected OOM (slow), got {inv.classified_path}"),
      
              # Step 7: t0_ambiguous propagated, escalation triggered with M2.
              (inv.t0_ambiguous is True, "expected t0_ambiguous=True on Investigation"),
              (inv.escalate_to_human is True, "expected escalation when T0 is ambiguous"),
              (any("M2" in r for r in inv.escalation_reasons), f"expected M2 escalation reason, got {inv.escalation_reasons}"),
      
              # Handoff payload carries t0_ambiguous flag.
              (inv.handoff["t0_ambiguous"] is True, "handoff must carry t0_ambiguous=True"),
              (inv.handoff["escalate_to_human"] is True, "handoff must carry escalate_to_human=True"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_06_ambiguous_t0")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_06_ambiguous_t0 ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  t0_ambiguous:    {inv.t0_ambiguous}")
          print(f"  escalate:        {inv.escalate_to_human} ({', '.join(inv.escalation_reasons)})")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_07_blast_radius.py 2.4 KB
      """
      Replay test for examples/07-blast-radius-asymmetric-revert.md.
      
      Stdlib only. Run with: `python tests/replay_07_blast_radius.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "07-blast-radius-asymmetric-revert"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-06-02T10:03:00Z",
              tnow_iso="2026-06-02T10:10:00Z",
              failing_surface_hints=["twilio", "sms"],
          )
      
          assertions = [
              # Step 2: one change in window, with bundle_size > 1.
              (len(inv.change_surface) == 1, f"expected 1 change in window, got {len(inv.change_surface)}"),
              (inv.change_surface[0].get("bundle_size") == 6, f"expected bundle_size=6, got {inv.change_surface[0].get('bundle_size')}"),
      
              # Step 3: classified as deploy-correlator.
              (inv.classified_path == "deploy-correlator", f"expected deploy-correlator, got {inv.classified_path}"),
      
              # Step 6: revert is still the top mitigation (methodology surfaces it).
              (inv.mitigation_ranked[0]["action"] == "revert", f"expected revert as top mitigation, got {inv.mitigation_ranked[0]['action']}"),
      
              # Step 7: ESCALATE because of M3 (bundle_size > 1).
              (inv.escalate_to_human is True, "must escalate when bundle_size > 1 on a revert-recommended path"),
              (any("M3" in r for r in inv.escalation_reasons), f"expected M3 escalation reason, got {inv.escalation_reasons}"),
              (any("6" in r for r in inv.escalation_reasons), f"expected M3 reason to mention 6 changes, got {inv.escalation_reasons}"),
      
              # Handoff carries the escalation explicitly.
              (inv.handoff["escalate_to_human"] is True, "handoff must carry escalate_to_human=True"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_07_blast_radius")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_07_blast_radius ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  recommended:     {inv.mitigation_ranked[0]['action']} (escalated: {inv.escalate_to_human})")
          print(f"  escalation:      {', '.join(inv.escalation_reasons)}")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_08_confirmation_bias.py 2.7 KB
      """
      Replay test for examples/08-deploy-correlator-confirmation-bias.md.
      
      Stdlib only. Run with: `python tests/replay_08_confirmation_bias.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "08-deploy-correlator-confirmation-bias"
      
      
      def main() -> int:
          # Failing surface hints point at auth / secret retrieval. The deploy diff does NOT
          # touch this surface; the RBAC change does. M4 guard should ensure deploy-correlator
          # is rejected.
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-07-11T14:33:00Z",
              tnow_iso="2026-07-11T14:38:00Z",
              failing_surface_hints=["secret", "auth", "rbac"],
          )
      
          assertions = [
              # Step 2: both changes in window (deploy + RBAC).
              (len(inv.change_surface) == 2, f"expected 2 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: NOT classified as deploy-correlator (M4 guard).
              (inv.classified_path != "deploy-correlator", f"M4 guard failed: classified as deploy-correlator despite mismatched diff surface"),
      
              # Should classify as outside-reference-paths (no reference path covers RBAC-correlator).
              (inv.classified_path == "outside-reference-paths", f"expected outside-reference-paths, got {inv.classified_path}"),
      
              # Step 6: NO revert of the deploy should be in mitigation list.
              (not any(m["action"] == "revert" and "users-api" in m.get("target", "") for m in inv.mitigation_ranked), "must NOT recommend reverting the (innocent) deploy"),
      
              # Step 7: ESCALATE with M1 (outside paths).
              (inv.escalate_to_human is True, "must escalate per M1"),
              (any("M1" in r for r in inv.escalation_reasons), f"expected M1 in reasons, got {inv.escalation_reasons}"),
      
              # Confirming signals should include change_audit for the RBAC change.
              (any(s["source"] == "change_audit" for s in inv.confirming_signals), f"expected change_audit signal for the RBAC change, got sources {[s['source'] for s in inv.confirming_signals]}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_08_confirmation_bias")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_08_confirmation_bias ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  escalate:        {inv.escalate_to_human} ({', '.join(inv.escalation_reasons)})")
          print(f"  M4 guard:        deploy correctly NOT classified as the cause despite timing")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_09_cert_expiry.py 2 KB
      """
      Replay test for examples/09-zero-changes-external-cert-expiry.md.
      
      Stdlib only. Run with: `python tests/replay_09_cert_expiry.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "09-zero-changes-external-cert-expiry"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-08-20T03:18:00Z",
              tnow_iso="2026-08-20T03:25:00Z",
          )
      
          assertions = [
              # Step 2: zero changes in window (this is the key signal for this scenario).
              (len(inv.change_surface) == 0, f"expected 0 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: outside-reference-paths (no internal classification fits).
              (inv.classified_path == "outside-reference-paths", f"expected outside-reference-paths, got {inv.classified_path}"),
      
              # Should NOT be classified as DNS just because there are TLS errors on outbound calls.
              # (TLS != DNS: the resolver works, the handshake fails.)
              (inv.classified_path != "DNS", "must distinguish TLS handshake failure from DNS resolution failure"),
      
              # Step 6: no revert (no change in window).
              (not any(m["action"] == "revert" for m in inv.mitigation_ranked), "must not recommend revert with zero changes"),
      
              # Step 7: ESCALATE per M1.
              (inv.escalate_to_human is True, "must escalate per M1"),
              (any("M1" in r for r in inv.escalation_reasons), f"expected M1, got {inv.escalation_reasons}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_09_cert_expiry")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_09_cert_expiry ({len(assertions)} assertions)")
          print(f"  classified_path: {inv.classified_path}")
          print(f"  escalate:        {inv.escalate_to_human}")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_10_multi_region.py 2.4 KB
      """
      Replay test for examples/10-multi-region-asymmetry.md.
      
      Stdlib only. Run with: `python tests/replay_10_multi_region.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "10-multi-region-asymmetry"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-09-14T11:42:00Z",
              tnow_iso="2026-09-14T11:50:00Z",
          )
      
          assertions = [
              (len(inv.change_surface) == 0, f"expected 0 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: outside-reference-paths (no reference path for regional config drift).
              (inv.classified_path == "outside-reference-paths", f"expected outside-reference-paths, got {inv.classified_path}"),
      
              # Regional asymmetry detected by the detector.
              (inv.regional_asymmetry.get("detected") is True, f"expected regional_asymmetry.detected=True, got {inv.regional_asymmetry}"),
              ("us-east-1" in inv.regional_asymmetry.get("per_region_peak_error_rate_pct", {}), "expected per-region breakdown to include us-east-1"),
              ("us-west-2" in inv.regional_asymmetry.get("per_region_peak_error_rate_pct", {}), "expected per-region breakdown to include us-west-2"),
      
              # ESCALATE with M1 + regional-asymmetry.
              (inv.escalate_to_human is True, "must escalate per M1 + regional asymmetry"),
              (any("regional" in r.lower() for r in inv.escalation_reasons), f"expected regional-asymmetry escalation, got {inv.escalation_reasons}"),
      
              # Handoff payload carries the regional asymmetry data.
              (inv.handoff["regional_asymmetry"].get("detected") is True, "handoff must include regional_asymmetry"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_10_multi_region")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_10_multi_region ({len(assertions)} assertions)")
          print(f"  classified_path:    {inv.classified_path}")
          print(f"  regional_asymmetry: {inv.regional_asymmetry.get('per_region_peak_error_rate_pct')}")
          print(f"  escalate:           {inv.escalate_to_human} ({', '.join(inv.escalation_reasons)})")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • replay_11_capacity_bound.py 2.3 KB
      """
      Replay test for examples/11-capacity-bound-organic-growth.md.
      
      Stdlib only. Run with: `python tests/replay_11_capacity_bound.py`.
      """
      
      from __future__ import annotations
      
      import sys
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      from _methodology import run_investigation  # noqa: E402
      
      FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "11-capacity-bound-organic-growth"
      
      
      def main() -> int:
          inv = run_investigation(
              fixture_dir=FIXTURE_DIR,
              t0_iso="2026-10-08T10:55:00Z",
              tnow_iso="2026-10-08T11:05:00Z",
          )
      
          assertions = [
              (len(inv.change_surface) == 0, f"expected 0 changes in window, got {len(inv.change_surface)}"),
      
              # Step 3: outside-reference-paths (no reference path for capacity saturation).
              (inv.classified_path == "outside-reference-paths", f"expected outside-reference-paths, got {inv.classified_path}"),
      
              # Step 5: request rate growth detected (>=1.5x baseline across window).
              (inv.blast_radius.get("request_rate_growing") is True, f"expected request_rate_growing=True, got {inv.blast_radius.get('request_rate_growing')}"),
      
              # Step 6: scale_resource is in the recommendation list (the key behavior for this scenario).
              (any(m["action"] == "scale_resource" for m in inv.mitigation_ranked), f"expected scale_resource in mitigations, got {[m['action'] for m in inv.mitigation_ranked]}"),
      
              # No revert recommendation (no change to revert).
              (not any(m["action"] == "revert" for m in inv.mitigation_ranked), "must not recommend revert with zero changes"),
      
              # Step 7: ESCALATE per M1.
              (inv.escalate_to_human is True, "must escalate per M1"),
              (any("M1" in r for r in inv.escalation_reasons), f"expected M1, got {inv.escalation_reasons}"),
          ]
      
          failed = [msg for ok, msg in assertions if not ok]
          if failed:
              print("FAIL: replay_11_capacity_bound")
              for msg in failed:
                  print(f"  - {msg}")
              return 1
      
          print(f"PASS: replay_11_capacity_bound ({len(assertions)} assertions)")
          print(f"  classified_path:       {inv.classified_path}")
          print(f"  request_rate_growing:  {inv.blast_radius.get('request_rate_growing')}")
          print(f"  recommended actions:   {', '.join(m['action'] for m in inv.mitigation_ranked)}")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • _methodology.py 22.6 KB
      """
      Reference implementation of the kubectl-investigator methodology.
      
      This module is a deterministic stand-in for what an AI agent does when it
      follows SKILL.md. It exists so replay tests can assert that the methodology,
      applied to known fixtures, produces the expected classification, mitigation,
      and handoff payload.
      
      Stdlib only. No external dependencies. No external credentials. Runs anywhere
      Python 3.10+ runs.
      """
      
      from __future__ import annotations
      
      import json
      from dataclasses import dataclass, field
      from datetime import datetime, timedelta
      from pathlib import Path
      from typing import Any
      
      WINDOW_LEAD_IN = timedelta(minutes=15)
      
      
      def _parse_ts(s: str) -> datetime:
          return datetime.fromisoformat(s.replace("Z", "+00:00"))
      
      
      def _in_window(ts_iso: str, window: tuple[datetime, datetime]) -> bool:
          ts = _parse_ts(ts_iso)
          return window[0] <= ts <= window[1]
      
      
      @dataclass
      class Investigation:
          """Structured output of the methodology, one per incident."""
      
          t0: datetime
          tnow: datetime
          window: tuple[datetime, datetime]
          change_surface: list[dict[str, Any]] = field(default_factory=list)
          classified_path: str = ""
          classification_evidence: list[str] = field(default_factory=list)
          confirming_signals: list[dict[str, str]] = field(default_factory=list)
          blast_radius: dict[str, Any] = field(default_factory=dict)
          mitigation_ranked: list[dict[str, str]] = field(default_factory=list)
          handoff: dict[str, Any] = field(default_factory=dict)
          escalate_to_human: bool = False
          escalation_reasons: list[str] = field(default_factory=list)
          t0_ambiguous: bool = False
          regional_asymmetry: dict[str, Any] = field(default_factory=dict)
      
      
      def load_fixture(fixture_dir: Path, name: str) -> Any:
          """Load a fixture file. .json returns parsed JSON; .jsonl returns a list."""
          path = fixture_dir / name
          if name.endswith(".jsonl"):
              with path.open() as f:
                  return [json.loads(line) for line in f if line.strip()]
          with path.open() as f:
              return json.load(f)
      
      
      def anchor_window(t0_iso: str, tnow_iso: str) -> tuple[datetime, datetime, tuple[datetime, datetime]]:
          """Step 1: anchor T0 and Tnow, return the [T0 - 15min, Tnow] window."""
          t0 = _parse_ts(t0_iso)
          tnow = _parse_ts(tnow_iso)
          if t0 > tnow:
              raise ValueError("T0 cannot be after Tnow")
          window = (t0 - WINDOW_LEAD_IN, tnow)
          return t0, tnow, window
      
      
      def bisect_change_surface(deploys_fixture: dict, window: tuple[datetime, datetime]) -> list[dict]:
          """Step 2: pull every change event overlapping the window."""
          changes = []
          for deploy in deploys_fixture.get("deploys", []):
              if _in_window(deploy["deployed_at"], window):
                  changes.append({**deploy, "kind": "deploy"})
          for change in deploys_fixture.get("infra_changes", []):
              if _in_window(change.get("changed_at", ""), window):
                  changes.append({**change, "kind": "infra"})
          for change in deploys_fixture.get("rbac_changes", []):
              if _in_window(change.get("changed_at", ""), window):
                  changes.append({**change, "kind": "rbac"})
          for flip in deploys_fixture.get("feature_flags", []):
              if _in_window(flip.get("flipped_at", ""), window):
                  changes.append({**flip, "kind": "feature_flag"})
          for job in deploys_fixture.get("scheduled_jobs", []):
              if _in_window(job.get("ran_at", ""), window):
                  changes.append({**job, "kind": "scheduled_job"})
          return changes
      
      
      def _has_oom_signature(pod_events: list[dict], metrics: dict, window: tuple[datetime, datetime]) -> tuple[bool, list[str]]:
          evidence = []
          oom_events = [e for e in pod_events if e.get("reason") == "OOMKilled" and _in_window(e["t"], window)]
          if oom_events:
              evidence.append(f"{len(oom_events)} OOMKilled events on pods")
          limit = metrics.get("memory_limit_bytes", 0)
          if limit:
              peak = max((s.get("rss_bytes_p95", 0) for s in metrics.get("samples", []) if _in_window(s["t"], window)), default=0)
              if peak >= 0.9 * limit:
                  evidence.append(f"RSS p95 reached {peak} bytes against limit {limit} bytes (>=90%)")
          # GC pause growth (5x baseline or higher) is a slow-burn OOM signature even without
          # an OOMKill yet. Covers the leak-trajectory case where memory hasn't quite hit the
          # limit but is heading there.
          samples_with_gc = [s for s in metrics.get("samples", []) if _in_window(s["t"], window) and "gc_pause_p99_ms" in s]
          if len(samples_with_gc) >= 2:
              baseline_gc = samples_with_gc[0]["gc_pause_p99_ms"]
              peak_gc = max(s["gc_pause_p99_ms"] for s in samples_with_gc)
              if baseline_gc > 0 and peak_gc >= 5 * baseline_gc:
                  evidence.append(f"GC pause p99 grew from {baseline_gc}ms to {peak_gc}ms ({peak_gc / baseline_gc:.1f}x baseline)")
          return (len(evidence) >= 2, evidence)
      
      
      def _has_dns_signature(logs: list[dict], window: tuple[datetime, datetime]) -> tuple[bool, list[str]]:
          evidence = []
          dns_error_markers = ("NXDOMAIN", "SERVFAIL", "getaddrinfo", "no such host", "dns resolution")
          matching = [log for log in logs if _in_window(log["t"], window) and any(m.lower() in log.get("msg", "").lower() for m in dns_error_markers)]
          if matching:
              evidence.append(f"{len(matching)} DNS error log lines in window")
          return (len(matching) >= 3, evidence)
      
      
      def _has_cascade_signature(metrics: dict, window: tuple[datetime, datetime]) -> tuple[bool, list[str]]:
          evidence = []
          samples = [s for s in metrics.get("samples", []) if _in_window(s["t"], window)]
          if len(samples) < 2:
              return (False, evidence)
          baseline = samples[0].get("gateway_retry_rate_rps", 0)
          peak = max((s.get("gateway_retry_rate_rps", 0) for s in samples), default=0)
          if baseline > 0 and peak >= 3 * baseline:
              evidence.append(f"gateway retry rate spiked from {baseline} to {peak} rps ({peak / baseline:.1f}x baseline)")
              return (True, evidence)
          # Cascade can also surface as upstream latency growth without a retry-rate spike
          # (thread-pool saturation pattern). Detect that as a secondary check.
          upstream_p99_field = "upstream_latency_p99_ms"
          if all(upstream_p99_field in s for s in samples):
              baseline_lat = samples[0][upstream_p99_field]
              peak_lat = max(s[upstream_p99_field] for s in samples)
              if baseline_lat > 0 and peak_lat >= 3 * baseline_lat:
                  evidence.append(f"upstream P99 latency grew from {baseline_lat}ms to {peak_lat}ms ({peak_lat / baseline_lat:.1f}x baseline)")
                  return (True, evidence)
          return (False, evidence)
      
      
      def _detect_regional_asymmetry(metrics: dict, window: tuple[datetime, datetime]) -> dict[str, Any]:
          """Detect per-region asymmetry in error rate. Empty dict if no region field in fixtures."""
          samples = [s for s in metrics.get("samples", []) if _in_window(s["t"], window) and "region" in s]
          if not samples:
              return {}
          regions: dict[str, list[float]] = {}
          for s in samples:
              regions.setdefault(s["region"], []).append(s.get("error_rate_pct", 0))
          if len(regions) < 2:
              return {}
          region_max = {r: max(vals) for r, vals in regions.items()}
          worst = max(region_max.values())
          best = min(region_max.values())
          if worst >= 5 * max(best, 0.5):
              return {
                  "detected": True,
                  "per_region_peak_error_rate_pct": region_max,
                  "asymmetry_ratio": round(worst / max(best, 0.5), 1),
              }
          return {"detected": False, "per_region_peak_error_rate_pct": region_max}
      
      
      def _deploy_correlator_evidence(changes: list[dict], failing_surface_hints: list[str]) -> list[str]:
          evidence = []
          for change in changes:
              if change["kind"] != "deploy":
                  continue
              diff = change.get("diff_summary", "").lower()
              for hint in failing_surface_hints:
                  if hint.lower() in diff:
                      evidence.append(f"deploy {change.get('version', '?')} ({change.get('commit', '?')}) touches failing surface: {hint}")
                      break
          return evidence
      
      
      def classify(
          pod_events: list[dict],
          metrics: dict,
          logs: list[dict],
          changes: list[dict],
          window: tuple[datetime, datetime],
          failing_surface_hints: list[str] | None = None,
      ) -> tuple[str, list[str]]:
          """Step 3: classify against the four reference paths.
      
          Returns (path, evidence_lines). Path is one of:
          "OOM", "DNS", "cascading-failure", "deploy-correlator", "outside-reference-paths".
      
          When OOM is triggered by a deploy, returns "OOM" (the OOM is the root path; the
          deploy explains the trigger but the failure shape is OOM). Reflected in the
          SKILL.md guidance: classify the primary path, note the cascade or trigger as
          second-order, do not collapse into a single label.
          """
          failing_surface_hints = failing_surface_hints or []
      
          oom_hit, oom_evidence = _has_oom_signature(pod_events, metrics, window)
          dns_hit, dns_evidence = _has_dns_signature(logs, window)
          cascade_hit, cascade_evidence = _has_cascade_signature(metrics, window)
          deploy_evidence = _deploy_correlator_evidence(changes, failing_surface_hints)
      
          if oom_hit:
              evidence = oom_evidence + deploy_evidence + cascade_evidence
              return ("OOM", evidence)
          if dns_hit:
              evidence = dns_evidence + deploy_evidence
              return ("DNS", evidence)
          if cascade_hit and not oom_hit and not dns_hit:
              return ("cascading-failure", cascade_evidence)
          if deploy_evidence and not cascade_hit:
              return ("deploy-correlator", deploy_evidence)
          return ("outside-reference-paths", ["no reference-path signature met threshold"])
      
      
      def confirm_signals(
          pod_events: list[dict],
          metrics: dict,
          logs: list[dict],
          changes: list[dict],
          traces: list[dict],
          window: tuple[datetime, datetime],
      ) -> list[dict[str, str]]:
          """Step 4: list signals supporting the hypothesis, drawn from independent sources.
      
          Each source contributes at most one entry; this enforces the "two signals from the
          same source count as one" rule from SKILL.md step 4.
          """
          signals: list[dict[str, str]] = []
      
          # orchestrator_events source
          oom_events = [e for e in pod_events if e.get("reason") == "OOMKilled" and _in_window(e["t"], window)]
          if oom_events:
              signals.append({"source": "orchestrator_events", "evidence": f"{len(oom_events)} OOMKilled pod events in window"})
      
          # metrics source: any of memory-at-limit, upstream-latency growth, retry-rate spike, error-rate jump.
          samples = [s for s in metrics.get("samples", []) if _in_window(s["t"], window)]
          if samples:
              metric_evidence_parts = []
              limit = metrics.get("memory_limit_bytes", 0)
              peak_rss = max((s.get("rss_bytes_p95", 0) for s in samples), default=0)
              if limit and peak_rss >= 0.9 * limit:
                  metric_evidence_parts.append(f"RSS p95 peak {peak_rss}B vs limit {limit}B")
              if all("upstream_latency_p99_ms" in s for s in samples):
                  up_baseline = samples[0]["upstream_latency_p99_ms"]
                  up_peak = max(s["upstream_latency_p99_ms"] for s in samples)
                  if up_baseline > 0 and up_peak >= 3 * up_baseline:
                      metric_evidence_parts.append(f"upstream P99 latency {up_baseline}ms -> {up_peak}ms ({up_peak / up_baseline:.1f}x)")
              retry_baseline = samples[0].get("gateway_retry_rate_rps", 0)
              retry_peak = max((s.get("gateway_retry_rate_rps", 0) for s in samples), default=0)
              if retry_baseline > 0 and retry_peak >= 3 * retry_baseline:
                  metric_evidence_parts.append(f"gateway retry rate {retry_baseline} -> {retry_peak} rps ({retry_peak / retry_baseline:.1f}x)")
              err_baseline = samples[0].get("error_rate_pct", 0)
              err_peak = max(s.get("error_rate_pct", 0) for s in samples)
              if err_peak >= max(1.0, 3 * err_baseline):
                  metric_evidence_parts.append(f"error rate {err_baseline}% -> {err_peak}%")
              if metric_evidence_parts:
                  signals.append({"source": "metrics", "evidence": "; ".join(metric_evidence_parts)})
      
          # change_audit / deploy_diff sources
          deploy_changes = [c for c in changes if c["kind"] == "deploy"]
          if deploy_changes:
              signals.append({"source": "deploy_diff", "evidence": f"deploy {deploy_changes[0].get('version', '?')} in window: {deploy_changes[0].get('diff_summary', '')[:120]}"})
          non_deploy_changes = [c for c in changes if c["kind"] != "deploy"]
          if non_deploy_changes:
              first = non_deploy_changes[0]
              descriptor = first.get("resource") or first.get("flag") or first.get("job") or first["kind"]
              signals.append({"source": "change_audit", "evidence": f"{first['kind']} change in window: {descriptor} - {first.get('diff_summary', '')[:120]}"})
      
          # traces source
          fail_traces = [t for t in traces if _in_window(t["t"], window) and t.get("outcome") == "fail"]
          if fail_traces:
              signals.append({"source": "traces", "evidence": f"{len(fail_traces)} failing distributed traces in window"})
      
          # logs source: DNS errors, timeout errors, thread-pool saturation, OOM logs.
          log_markers = (
              "NXDOMAIN", "SERVFAIL", "getaddrinfo", "no such host",
              "timeout", "thread pool saturated", "out of memory",
              "retry budget exhausted",
          )
          log_hits = [log for log in logs if _in_window(log["t"], window) and any(m.lower() in log.get("msg", "").lower() for m in log_markers)]
          if log_hits:
              signals.append({"source": "logs", "evidence": f"{len(log_hits)} error log lines in window (timeout / saturation / DNS / OOM patterns)"})
      
          return signals
      
      
      def blast_radius(metrics: dict, window: tuple[datetime, datetime]) -> dict[str, Any]:
          """Step 5: quantify users / surfaces / business impact from telemetry."""
          samples = [s for s in metrics.get("samples", []) if _in_window(s["t"], window)]
          if not samples:
              return {"users_affected_pct": None, "request_rate_peak": None, "error_rate_peak_pct": None, "request_rate_growing": False}
          rates = [s.get("request_rate_rps", 0) for s in samples]
          request_rate_growing = False
          if len(rates) >= 3 and rates[0] > 0:
              request_rate_growing = rates[-1] >= 1.5 * rates[0]
          return {
              "users_affected_pct": max(s.get("error_rate_pct", 0) for s in samples),
              "request_rate_peak": max(rates),
              "error_rate_peak_pct": max(s.get("error_rate_pct", 0) for s in samples),
              "request_rate_growing": request_rate_growing,
          }
      
      
      def propose_mitigation(
          classified_path: str,
          changes: list[dict],
          blast: dict[str, Any],
      ) -> list[dict[str, str]]:
          """Step 6: ordered mitigation list. Mitigation before root cause."""
          actions: list[dict[str, str]] = []
          # Revert the implicated change. Prefer reverting the change in window (code deploy,
          # cluster/config change, RBAC change, or feature flag flip), whichever is the implicated
          # single change. Methodology rule: mitigation before root cause.
          revertable = [c for c in changes if c["kind"] in ("deploy", "infra", "rbac", "feature_flag")]
          if revertable and classified_path in ("OOM", "deploy-correlator", "cascading-failure", "DNS"):
              change = revertable[0]
              if change["kind"] == "deploy":
                  target = f"{change.get('service', '?')} to previous version"
                  note = f"deploy {change.get('version', '?')} ({change.get('commit', '?')}) is in window and touches the failing surface"
              elif change["kind"] == "infra":
                  target = f"{change.get('resource', '?')} to pre-{change.get('changed_at', '?')} state"
                  note = f"cluster/infra change to {change.get('resource', '?')} is in window and touches the failing surface"
              elif change["kind"] == "rbac":
                  target = f"{change.get('resource', '?')} RBAC change"
                  note = f"RBAC change to {change.get('resource', '?')} is in window and touches the failing surface"
              else:  # feature_flag
                  target = f"{change.get('flag', '?')} feature flag"
                  note = f"feature flag {change.get('flag', '?')} was flipped in window"
              actions.append({"action": "revert", "target": target, "note": note})
          # Pure cascade with no in-window change: circuit-breaker / traffic-shift come first.
          if classified_path == "cascading-failure" and not revertable:
              actions.append({
                  "action": "circuit_breaker",
                  "target": "open circuit on the degraded upstream dependency",
                  "note": "stops the retry storm from amplifying the underlying degradation. Does not fix the upstream itself.",
              })
              actions.append({
                  "action": "traffic_shift",
                  "target": "shed traffic from the saturating service or shift to a healthy replica",
                  "note": "buys time while the upstream recovers or is scaled.",
              })
          if classified_path == "OOM":
              actions.append({
                  "action": "scale_resource",
                  "target": "memory limit",
                  "note": "stopgap if revert is delayed. Does not address cause. Will increase per-pod cost.",
              })
          if classified_path == "DNS":
              actions.append({
                  "action": "traffic_shift",
                  "target": "regions / resolvers not affected by DNS issue",
                  "note": "if asymmetric. Verify the unaffected path actually bypasses the broken resolver before shifting traffic.",
              })
          # Outside reference paths with sustained traffic growth: scaling is a valid first action.
          if classified_path == "outside-reference-paths" and blast.get("request_rate_growing"):
              actions.append({
                  "action": "scale_resource",
                  "target": "horizontal scale of the saturated service",
                  "note": "request rate has been climbing across the window and headroom is exhausted. Scaling addresses the immediate cause; capacity planning addresses the root cause.",
              })
          actions.append({
              "action": "manual_intervention",
              "target": "restart affected pods / processes",
              "note": "last resort. Does not address cause. Failure will recur.",
          })
          return actions
      
      
      def maybe_escalate(
          classified_path: str,
          changes: list[dict],
          signals: list[dict[str, str]],
          blast: dict[str, Any],
          t0_ambiguous: bool = False,
          regional_asymmetry: dict[str, Any] | None = None,
      ) -> tuple[bool, list[str]]:
          """Aggregate the FAILURE_MODES.md escalation rules.
      
          Implemented checks: M1 (outside reference paths), M2 (ambiguous T0),
          M3 (revert blast-radius exceeds incident), the M-bias three-independent-signals
          guard, and the regional-asymmetry surfacing as a soft signal. M4 (deploy-correlator
          confirmation bias) is handled structurally by classify(), which requires
          diff-touches-failing-surface evidence before classifying as deploy-correlator;
          a timing-only correlation falls through to outside-reference-paths and trips M1.
          The operational rules (O1 missing sources, O2 telemetry blackout, O3 multi-incident)
          are surfaced as fixture-schema follow-ups.
          """
          reasons = []
          if classified_path == "outside-reference-paths":
              reasons.append("M1: failure classified outside the four reference paths")
          if len({s["source"] for s in signals}) < 3:
              reasons.append("M-bias: fewer than three independent signal sources")
          if t0_ambiguous:
              reasons.append("M2: T0 is ambiguous; re-run with a widened window before acting on the recommended mitigation")
          # M3: revert blast radius vs incident blast radius.
          revertable = [c for c in changes if c["kind"] in ("deploy", "infra", "rbac", "feature_flag")]
          if revertable and classified_path in ("OOM", "deploy-correlator", "cascading-failure", "DNS"):
              change = revertable[0]
              bundle_size = change.get("bundle_size", 1)
              if bundle_size > 1:
                  reasons.append(
                      f"M3: recommended revert affects {bundle_size} bundled changes, broader blast radius than the incident; requires a human approver before executing"
                  )
          if regional_asymmetry and regional_asymmetry.get("detected"):
              reasons.append(
                  f"regional-asymmetry: per-region error-rate peaks {regional_asymmetry.get('per_region_peak_error_rate_pct')}; treat as a config-drift hypothesis and escalate"
              )
          return (len(reasons) > 0, reasons)
      
      
      def handoff_payload(investigation: Investigation) -> dict[str, Any]:
          """Step 7: structured handoff for postmortem-author."""
          return {
              "t0": investigation.t0.isoformat(),
              "t0_ambiguous": investigation.t0_ambiguous,
              "tnow": investigation.tnow.isoformat(),
              "classified_path": investigation.classified_path,
              "evidence": investigation.classification_evidence,
              "confirming_signals": investigation.confirming_signals,
              "blast_radius": investigation.blast_radius,
              "regional_asymmetry": investigation.regional_asymmetry,
              "mitigation_recommended": investigation.mitigation_ranked[0] if investigation.mitigation_ranked else None,
              "mitigation_alternatives": investigation.mitigation_ranked[1:],
              "escalate_to_human": investigation.escalate_to_human,
              "escalation_reasons": investigation.escalation_reasons,
          }
      
      
      def run_investigation(
          fixture_dir: Path,
          t0_iso: str,
          tnow_iso: str,
          failing_surface_hints: list[str] | None = None,
          t0_ambiguous: bool = False,
      ) -> Investigation:
          """End-to-end: load fixtures, run steps 1-7, return the structured investigation."""
          deploys = load_fixture(fixture_dir, "deploys.json")
          pod_events = load_fixture(fixture_dir, "pod_events.jsonl") if (fixture_dir / "pod_events.jsonl").exists() else []
          metrics = load_fixture(fixture_dir, "metrics.json")
          logs = load_fixture(fixture_dir, "logs.jsonl") if (fixture_dir / "logs.jsonl").exists() else []
          traces = load_fixture(fixture_dir, "traces.jsonl") if (fixture_dir / "traces.jsonl").exists() else []
      
          t0, tnow, window = anchor_window(t0_iso, tnow_iso)
          changes = bisect_change_surface(deploys, window)
          path, evidence = classify(pod_events, metrics, logs, changes, window, failing_surface_hints)
          signals = confirm_signals(pod_events, metrics, logs, changes, traces, window)
          blast = blast_radius(metrics, window)
          regional = _detect_regional_asymmetry(metrics, window)
          mitigation = propose_mitigation(path, changes, blast)
          escalate, escalation_reasons = maybe_escalate(path, changes, signals, blast, t0_ambiguous=t0_ambiguous, regional_asymmetry=regional)
      
          investigation = Investigation(
              t0=t0,
              tnow=tnow,
              window=window,
              change_surface=changes,
              classified_path=path,
              classification_evidence=evidence,
              confirming_signals=signals,
              blast_radius=blast,
              mitigation_ranked=mitigation,
              escalate_to_human=escalate,
              escalation_reasons=escalation_reasons,
              t0_ambiguous=t0_ambiguous,
              regional_asymmetry=regional,
          )
          investigation.handoff = handoff_payload(investigation)
          return investigation
      
  • FAILURE_MODES.md 6.8 KB
    # Failure modes: `kubectl-investigator`
    
    This skill is wrong in predictable ways. The list below is the reason it ships with a quality bar that mandates fixture-based replay tests: every failure mode here is a regression vector and gets a test once it shows up in the wild.
    
    ## Methodology-level failure modes
    
    ### M1. Force-fitting a novel failure into the four reference paths
    
    The skill classifies against four canonical paths in step 3 (OOM, DNS, cascading-failure, deploy-correlator). When the real failure is none of these, the agent will sometimes pick the closest path and proceed, producing a confident-looking but wrong hypothesis.
    
    **Mitigation in the methodology**: step 4 (three independent signals) is the guard. If signals do not converge on the chosen path, the agent must explicitly mark "outside reference paths" rather than picking the closest.
    
    **Where it breaks anyway**: when the agent has only one or two strong signals and silently treats a weak third signal as confirming. Watch for this pattern in test failures.
    
    **Escalation rule**: if the agent has classified outside the four paths, escalate to a human before any mitigation beyond traffic-shift or feature-flag.
    
    ### M2. Wrong T0 anchors the entire investigation
    
    Step 1 picks T0 as the first user-visible symptom. When T0 is set 20 minutes late, the change-surface bisection in step 2 misses the actual triggering change, and the agent confidently exonerates a rollout that did in fact cause the incident.
    
    **Mitigation in the methodology**: the 15-minute lead-in in step 2 catches most cases. When the symptom built up slowly (e.g. memory leak with a slow OOM), the agent must widen the window deliberately and document the ambiguity.
    
    **Escalation rule**: if T0 is documented as "ambiguous" in the timeline, the recommended mitigation must include a re-check after the window is widened.
    
    ### M3. Mitigation recommended past safe blast radius
    
    Step 5 (quantify blast radius) and step 6 (propose mitigation) are deliberately ordered, but the agent can still recommend a revert that affects more surface than the incident itself if the change identified in step 2 was bundled with unrelated changes.
    
    **Escalation rule**: any mitigation whose blast radius exceeds the incident's blast radius requires a human approver. The agent must surface the asymmetry explicitly rather than recommending the revert.
    
    ### M4. Confirmation bias on the deploy-correlator path
    
    Temporal coincidence with a rollout is the easiest path for the agent to find and the easiest to over-trust. The rollout is often correlated but not causal (e.g. the rollout and the failure both follow from a third event like an HPA/capacity event or a ConfigMap/RBAC change applied minutes earlier).
    
    **Mitigation in the methodology**: step 4 requires three independent signals. For the deploy-correlator path specifically, two of those signals must be drawn from the rollout diff itself (e.g. the diff touches the failing code path, the new ReplicaSet / canary shows asymmetry) rather than just from timing.
    
    ## Operational failure modes
    
    ### O1. Stale or missing change-event sources
    
    Step 2 assumes the agent can query rollout events, RBAC audit logs, and config-change (ConfigMap/Secret) history. When one of these sources is missing or stale by more than the window, the bisection silently omits a category of changes.
    
    **Mitigation**: the agent must enumerate which sources it queried and call out any it could not reach.
    
    **Escalation rule**: if more than one change-event source is unavailable, escalate before recommending mitigation other than feature-flag-off.
    
    ### O2. Telemetry blackout during the incident
    
    A serious outage can take observability with it (the metrics pipeline is itself the affected dependency). Step 4 (three independent signals) cannot be satisfied when the signal source is the thing that broke.
    
    **Escalation rule**: telemetry blackout is an immediate escalation to a human. The skill returns the partial timeline and the hypothesis but does not recommend mitigation.
    
    ### O3. Multi-incident interleaving
    
    When two unrelated incidents overlap in time, the change-surface bisection in step 2 returns changes for both, and the classification in step 3 may produce a hybrid hypothesis that explains neither cleanly.
    
    **Mitigation in the methodology**: when steps 3 and 4 produce two competing hypotheses that each have strong signals, treat them as parallel incidents and run the methodology separately on each.
    
    **Escalation rule**: if the agent identifies two parallel incidents, escalate. Humans coordinate multi-incident response better than agents.
    
    ## When to escalate to a human (summary)
    
    Escalate immediately when **any** of the following is true:
    
    - The failure is classified outside the four reference paths.
    - T0 is documented as ambiguous.
    - The recommended mitigation has broader blast radius than the incident.
    - More than one change-event source is unavailable.
    - Telemetry blackout prevents satisfying the three-independent-signals requirement.
    - Two parallel incidents are detected.
    
    Escalation does not mean the agent stops working. It means: surface the timeline, the partial hypothesis, the gaps, and the recommended next step. Then wait for the human.
    
    ## Implementation status
    
    The reference implementation in `tests/_methodology.py` currently enforces:
    
    - **M1** (outside reference paths) and the three-independent-signals guard. Covered by tests `replay_01` through `replay_11`.
    - **M2** (ambiguous T0). Caller flags ambiguity via the `t0_ambiguous` parameter; the methodology escalates accordingly. Covered by `replay_06_ambiguous_t0.py`.
    - **M3** (revert blast-radius asymmetry). Detected via the `bundle_size` field on deploys. Covered by `replay_07_blast_radius.py`.
    - **M4** (deploy-correlator confirmation bias). Handled structurally: the classifier requires diff-touches-failing-surface evidence before classifying as deploy-correlator. Covered by `replay_08_confirmation_bias.py`.
    - **Regional asymmetry**. Detected from per-region samples in metrics fixtures. Covered by `replay_10_multi_region.py`.
    
    The operational rules (**O1** missing change-event sources, **O2** telemetry blackout, **O3** multi-incident interleaving) require richer fixture schemas to detect deterministically and are not yet enforced by the reference implementation. Contributions welcome.
    
    ## How to add a new failure mode here
    
    When a replay test catches a misclassification, or a real-world use surfaces a new failure pattern, add it under "Methodology-level" or "Operational" with:
    
    1. A short name (`M5`, `O4`, ...).
    2. The failure shape, in one sentence.
    3. Whatever the methodology already does about it (mitigation in the methodology).
    4. The escalation rule for it.
    
    Then add a regression test under `tests/` that asserts the methodology produces the correct response in the failure-mode scenario, even if the response is "escalate, do not recommend mitigation".
    
  • README.md 4 KB
    # kubectl-investigator
    
    Methodology-shaped SRE skill for investigating a live or recent incident on **Kubernetes**.
    
    Anchors the incident window, bisects the change surface (rollouts, ConfigMaps/Secrets, RBAC, HPA/cluster changes, CronJobs), classifies the failure against four reference paths (OOM, DNS, cascading-failure, deploy-correlator), confirms with three independent signals, quantifies blast radius, and proposes mitigation before root cause.
    
    ## Files in this skill
    
    | File | What it is |
    |---|---|
    | [`SKILL.md`](./SKILL.md) | The methodology. This is what an AI agent loads. |
    | [`examples/`](./examples/) | Eleven worked examples covering the four reference paths, the FAILURE_MODES escalation rules, and edge cases. |
    | [`fixtures/`](./fixtures/) | Committed telemetry / event snapshots (pod events, metrics, logs, traces, rollout/RBAC/cluster changes) that drive the replay tests. No live cluster or credentials required. |
    | [`tests/`](./tests/) | Replay tests that exercise the methodology against the fixtures. |
    | [`FAILURE_MODES.md`](./FAILURE_MODES.md) | Where this skill is wrong and where the agent should escalate. |
    
    ## Quality bar (this skill passes all three)
    
    - [x] Two worked examples required by the bar; this skill ships [eleven](./examples/) covering the four reference paths, the FAILURE_MODES escalation rules, and edge cases.
    - [x] Fixture-based replay tests, runnable with no live cluster or credentials. 99 assertions across the 11 tests (`for t in tests/replay_*.py; do python "$t" || exit 1; done`).
    - [x] Explicit failure-modes section ([`FAILURE_MODES.md`](./FAILURE_MODES.md)).
    
    ## Measured lift
    
    An LLM ablation eval is committed under [`tests/eval/`](./tests/eval/). An automated run with Claude Sonnet 4.6 as both agent and LLM-judge (**N=3 trials per cell**, 66 trials, scored against the 7-item rubric in [`rubric.md`](./tests/eval/rubric.md)) measured a **+0.82 / 7 (+15%) lift** of an agent loaded with this `SKILL.md` (mean 6.36) over an agent given the same telemetry with no methodology (mean 5.55). Treatment wins on 9 of 11 fixtures, ties on 2, and **loses on none**; the largest lifts are on the escalation cases the cold agent doesn't know to guard against (third-party rate-limit +2.33, confirmation-bias +1.67, capacity-bound +1.33). See [`tests/eval/README.md`](./tests/eval/README.md) for the full per-fixture table and the honest caveats on the methodology. Reproduce with `python tests/eval/run_eval.py --trials 3`.
    
    ## How to use
    
    ### As a Claude Code / Claude Skills user
    
    Drop `skills/kubectl-investigator/` into your skills directory and invoke when a Kubernetes incident is in progress. The agent reads `SKILL.md` and follows the methodology end-to-end against your cluster telemetry (kubectl, the events API, kube-state-metrics, Prometheus).
    
    ### As a contributor adding a new reference path or example
    
    1. Add a new example file under `examples/` mirroring the existing ones.
    2. Commit fixtures under `fixtures/<example-slug>/` (pod events, metrics, traces, logs, rollout/RBAC/cluster changes as relevant).
    3. Add a replay test under `tests/replay_NN_<example-slug>.py` that asserts the methodology produces the correct classification + mitigation.
    4. Update [`SKILL.md`](./SKILL.md) if the new path is reference-quality (i.e. covers >5% of real Kubernetes incidents); otherwise keep it in `examples/` only.
    
    See the top-level [`CONTRIBUTING.md`](../../CONTRIBUTING.md) for the repo-wide bar.
    
    ## Anyshift integration (opt-in)
    
    The methodology runs vendor-neutral by default (any cluster, kubectl + your telemetry). Opting in to the [Anyshift MCP](https://www.anyshift.io) for step 2 (change-surface bisection) gives the agent a versioned resource graph that links rollouts, RBAC changes, and cluster/infrastructure changes to the Kubernetes resources implicated in the incident.
    
    A measured "with vs without" delta will be published in this section once the MCP integration has been exercised against the replay tests above. Numbers will replace this note directly.
    
    ## License
    
    [Apache 2.0](../../LICENSE).
    
  • SKILL.md 19.4 KB
    ---
    name: kubectl-investigator
    description: Investigate a live or recent incident in a Kubernetes cluster. Anchor the window, bisect the change surface (rollouts, ConfigMaps/Secrets, RBAC, HPA/cluster changes, CronJobs), classify against four reference failure paths (OOM, DNS, cascading-failure, deploy-correlator), confirm the hypothesis with three independent signals, quantify blast radius, and propose mitigation before root cause. Use whenever an agent is asked "what is breaking in the cluster right now", "why did this pod/Deployment just page", "did the rollout cause Z", or to triage an active Kubernetes incident. Vendor-neutral by default (works with kubectl, kube-state-metrics, and whatever telemetry you have); an opt-in Anyshift integration is documented separately.
    ---
    
    # kubectl-investigator
    
    Methodology skill for investigating a live or recent incident on **Kubernetes**. Produces a timeline, a ranked set of hypotheses, a blast-radius estimate, and a recommended mitigation. Hands off cleanly to `postmortem-author` once the incident is mitigated.
    
    Scope: workloads running on Kubernetes (Deployments, StatefulSets, DaemonSets, Jobs/CronJobs) and the cluster primitives around them (Services, Ingress, CoreDNS, ConfigMaps/Secrets, RBAC, HPA, nodes). External dependencies (third-party APIs, partner TLS endpoints, managed databases) are in scope only as seen *from* a Kubernetes workload — the methodology investigates the cluster-side symptom and the in-cluster change surface.
    
    ## When to invoke
    
    - A `PrometheusRule` / Alertmanager alert just fired on a workload and the agent needs to triage before paging a human.
    - A user asks "what is breaking in the cluster right now" or "why did Deployment X just page".
    - A `kubectl rollout` / Helm release / Argo CD sync went out in the last hour and a metric moved; need to know whether they are linked.
    - Pods are crash-looping, `OOMKilled`, or `Pending`, or customer impact is reported with no alert yet; need to find the failing surface.
    
    ## The methodology, in order
    
    The order matters. Skipping a step produces confident wrong answers.
    
    ### 1. Anchor the window
    
    Lock two timestamps before doing anything else:
    
    - **T0**: the trigger timestamp. Apply this order:
      1. **If an alert is provided as the trigger, T0 = alert fire time.** Use this verbatim. Do not substitute an earlier "first error in logs / first `OOMKilled` event" timestamp just because one exists; the alert fire time is the agreed-upon coordination point for the incident.
      2. **If a customer report is the trigger, T0 = report timestamp.**
      3. **If neither exists (operator-initiated investigation, "pods slow all morning"), T0 = earliest unambiguous signal in the available telemetry** (first `OOMKilled` event, first `SERVFAIL`, first error-rate inflection), and mark T0 as ambiguous (see below).
    - **Tnow**: current time, or the timestamp the investigation was triggered.
    
    Every later signal is filtered to `[T0 - 15min, Tnow]`. The 15-minute lead-in catches changes that landed just before the symptom surfaced (a rollout's pods take time to roll, an HPA scale-down takes time to bite).
    
    **If T0 is ambiguous** (operator-triggered with no alert, or "slow all morning"-class reports), the methodology's recommended mitigation in step 6 **must begin with "re-run the investigation with a widened window"** before any irreversible action. The change identified within the original narrow window is likely incomplete; the actual causal change may sit outside it. Do not silently round and do not skip the re-run step.
    
    ### 2. Bisect the change surface
    
    Pull every change event that overlaps the window. On Kubernetes the change surface is:
    
    - **Workload rollouts**: `kubectl rollout` / `kubectl apply` / `kubectl set image`, new image tags, new ReplicaSets, Helm releases, Argo CD / Flux syncs.
    - **Cluster / capacity changes**: node-pool scaling, node cordon/drain, resource `requests`/`limits` edits, HPA / VPA changes, PodDisruptionBudget edits, PV/PVC/StorageClass changes.
    - **RBAC / ServiceAccount changes**: Role / ClusterRole / RoleBinding / ClusterRoleBinding edits, ServiceAccount or its token/permissions changed (these break Secret reads, API access, admission).
    - **Config / feature-flag changes**: ConfigMap / Secret edits, CoreDNS `Corefile` ConfigMap edits, Ingress/NetworkPolicy changes, feature-flag flips.
    - **Admission / operator changes**: Validating/MutatingWebhookConfiguration edits, CRD or controller upgrades.
    - **CronJobs / Jobs** that ran in the window (batch, data migrations, cluster maintenance jobs).
    
    If the window has **zero** change events, treat it as a strong signal in itself: the failure is likely external (upstream provider, certificate expiry, DNS, capacity drift from organic growth) rather than a self-inflicted regression.
    
    ### 3. Classify against the four reference paths
    
    Match the failure shape to one of these four canonical paths first. They cover the majority of Kubernetes incidents; only branch out once they are ruled out.
    
    | Path | Tell-tale signals | Confirming evidence |
    |---|---|---|
    | **OOM** | Container restart count climbing, `CrashLoopBackOff`, RSS / working-set at the container memory limit, `OOMKilled` pod events, retry storm from upstream | Container exit code 137, `reason: OOMKilled` in pod events / `kubectl describe pod`, working-set metric at or above `resources.limits.memory` at T0, a recent rollout that increased per-pod memory footprint |
    | **DNS** | Connection failures with `NXDOMAIN` / `SERVFAIL` in logs, `getaddrinfo` / `no such host` errors, sudden latency on in-cluster Service calls, `*.svc.cluster.local` resolution failing while external hosts resolve | CoreDNS error / `SERVFAIL` counts elevated, a recent change to the CoreDNS `Corefile` ConfigMap, kube-dns/CoreDNS pod restarts, `ndots`/search-domain or NetworkPolicy change in the window |
    | **Cascading-failure** | One in-cluster dependency degrades, retry counts spike across callers, connection pools / thread pools / sidecar (Envoy) circuits saturate, queue depth grows | Latency increases hop-by-hop toward the root Service, retry-budget metrics, circuit-breaker state changes, 2nd-order Deployments start failing, upstream Pod `Unhealthy` / readiness-probe failures |
    | **Deploy-correlator** | Metric breaks within 5 minutes of a rollout on the failing surface, only pods from the new ReplicaSet show the symptom | Canary / blue-green or rolling-update split shows old-RS healthy / new-RS failing, `kubectl rollout undo` restores the metric, the rollout diff touches the failing code path |
    
    If the failure does not match any of the four, classify as **"outside reference paths"** and document why. Outside-reference-paths means the methodology has no reference path for the failure shape, so its confidence in the *root cause* is low and **escalation to a human is mandatory** (step 6). It does not mean the agent does nothing: a pre-approved safe mitigation (traffic-shift to a healthy peer, feature-flag-off) is still recommended as the top action when available, with the root-cause investigation escalated in parallel. See step 6 for the exact ordering.
    
    ### 4. Confirm with three independent signals
    
    Never declare a hypothesis on one signal. Require at least three of the following, drawn from independent sources:
    
    - **Pod / cluster events** (`kubectl get events`, kubelet: `OOMKilled`, `BackOff`, `Unhealthy`, `FailedScheduling`).
    - **Logs** (application container logs, system component logs).
    - **Metrics** (request rate, error rate, latency, saturation, working-set, CoreDNS error rate — from Prometheus / kube-state-metrics).
    - **Traces** (distributed traces showing the failing hop / Service).
    - **Change events** (rollouts, ConfigMap/Secret, RBAC, HPA/cluster changes).
    - **External signals** (customer reports, status pages of dependencies the workload calls).
    
    Two signals from the same source (e.g. two log lines) count as one. The independence requirement is the guard against confirmation bias.
    
    **Split aggregate signals before trusting them.** Error rate, latency, and saturation are usually reported as a single number across every region, cluster, AZ, shard, or canary/stable split. Before classifying, break each aggregate down along these dimensions. A **per-dimension asymmetry** — one region failing while its peer is healthy, one shard hot while the rest are flat — is a first-class diagnostic signal, and aggregate metrics actively hide it (a 25% failure in one of two equally-sized regions shows up as a moderate ~12% aggregate that matches no clean reference path).
    
    When the failing and healthy slices run the **same image tag / same code**, a code-regression path (OOM, deploy-correlator) is ruled out by construction: identical code cannot fail in one slice and not the other. The cause is environmental — config/GitOps drift, a stale Service reference, per-region capacity, an external dependency reachable from only one slice. **A confirmed asymmetry short-circuits the four-path search: stop trying to fit OOM/DNS/cascade/deploy-correlator on the aggregate, classify "outside reference paths" with a `regional-asymmetry` (or shard/AZ-asymmetry) reason, and move to step 5.** Continuing to hunt for a reference-path match on aggregate signals after an asymmetry is detected wastes the investigation and is the single most common way this step runs long.
    
    ### 5. Quantify blast radius
    
    Before recommending action, estimate:
    
    - **Users affected** (count or percentage of traffic).
    - **Surfaces affected** (which Services/endpoints, which namespaces, which clusters/regions, which customer segments).
    - **Business impact** (revenue, SLO burn, contractual obligations if known).
    
    A wrong mitigation that touches more surface than the incident itself is worse than the incident.
    
    ### 6. Propose mitigation before root cause
    
    Mitigation comes first. Root cause comes after the bleeding stops.
    
    **Hard constraints, in order. Check these before ranking the standard actions below:**
    
    - **If the classification from step 3 is "outside reference paths", escalation to a human is mandatory — but it is not automatically the *top* action.** Two mitigations are pre-approved as safe because they are reversible and contained, and when one of them is available it becomes the top recommended action:
      - **Traffic-shift away from the failing slice to a healthy peer** (other region/cluster/shard/replica). This is the canonical first move for a regional/shard asymmetry: it stops the bleeding immediately and is trivially reversible. When the asymmetry detector in step 4 has identified a healthy peer, *recommend the traffic-shift as action #1*, then escalate the root-cause investigation (config/GitOps drift, the failing dependency) to a human as the parallel follow-up.
      - **Feature-flag off the failing code path**, if a flag exists.
    
      Every *other* option — contacting an external provider, irreversible config/RBAC/state changes, anything touching the failing slice directly — is surfaced as an alternative for the human to approve, not executed by the agent. The principle (from FAILURE_MODES M1): outside-reference-paths means low confidence in *root cause*, so escalate the root cause; it does not forbid the safe, reversible mitigation that an on-call would reach for first.
    - **If T0 was flagged as ambiguous in step 1, the top recommended action is "re-run the investigation with a widened window".** Only after that re-run identifies a fuller change surface should any irreversible mitigation (`rollout undo`, RBAC change, cluster/infra rollback) be recommended.
    - **If the implicated change has `bundle_size > 1` (multiple changes shipped in one rollout), `rollout undo` remains the top recommendation but requires explicit human approval before execution.** Surface the asymmetry explicitly: "the rollback reverts N changes when the incident affects only K of them".
    - **If the classification is "cascading-failure", the top action is to break the amplification loop at its source, not to undo a rollout.** A pure cascade typically has no rollout in the window (the trigger is a degraded dependency, not a deploy), so there is nothing to revert. Recommend, in order: open the circuit breaker on / shed load from the **degraded dependency** itself (the root of the cascade), then cap or disable the retry budget at the callers driving the retry storm. Shedding the callers' retries alone treats the symptom (the amplification) while leaving the degraded dependency saturated; opening the circuit at the dependency stops the loop at its origin and lets the dependency recover.
    
    **Standard mitigation order (applies when the constraints above do not fire):**
    
    1. **`kubectl rollout undo`** the workload identified in step 2, if one rollout is clearly implicated and reversible (or revert the implicated ConfigMap / RBAC change).
    2. **Feature-flag off** the failing code path, if a flag exists.
    3. **Scale** the saturated resource (`kubectl scale` / raise the HPA ceiling / raise `resources.limits`), if the path is capacity-bound and not regression-bound.
    4. **Traffic-shift** away from the failing region / cluster / Service version / shard.
    5. **Manual intervention** (`kubectl delete pod` to force a fresh restart, kill a stuck Job) as a last resort, with explicit acknowledgement that it does not address cause — pods will recreate from the same broken spec.
    
    If no safe mitigation exists even after applying the above, surface that explicitly and escalate.
    
    ### 7. Hand off
    
    Produce a structured handoff for `postmortem-author`. All four elements below are **mandatory** and must appear as labelled sections, even when an element is empty (write "Open questions: none identified", not nothing — a silently missing section reads as "investigation incomplete" to the next responder):
    
    - **Timeline** (T0, key events, mitigation timestamp, Tresolved).
    - **Ranked hypotheses** with the evidence supporting each.
    - **Mitigation** taken / recommended and observed effect.
    - **Open questions** (gaps in signals, unverified assumptions, root-cause threads the mitigation did not close). This section is the most-often dropped and the most valuable to the postmortem: list every unresolved thread explicitly. If the investigation truly left no gaps, say so explicitly rather than omitting the heading.
    
    ## Output format
    
    The agent's final message in any invocation must include:
    
    1. **Anchored window**: `T0 = ..., Tnow = ...`.
    2. **Change surface**: bulleted list of overlapping changes (rollouts, ConfigMap/Secret, RBAC, HPA/cluster, CronJobs), or "no changes in window".
    3. **Classified path**: one of the four, or "outside reference paths" with justification.
    4. **Confirming signals**: three or more, each cited with source.
    5. **Blast radius**: users + surfaces + business impact.
    6. **Recommended mitigation**: ordered, with explicit "do not address cause" notes where applicable.
    7. **Handoff payload**: structured for `postmortem-author`, containing all four labelled sections from step 7 — **timeline**, **ranked hypotheses**, **mitigation**, and **open questions**. Do not collapse or omit any of them; an absent "open questions" section is treated as an incomplete handoff.
    
    ## Worked examples
    
    Eleven end-to-end examples are committed under `examples/`, each with fixtures and a runnable replay test.
    
    **Reference paths** (one canonical example per path):
    
    - [`examples/01-oom-cascade.md`](./examples/01-oom-cascade.md): OOM in a payments Deployment triggering a retry storm from the API gateway.
    - [`examples/02-dns-resolution-failure.md`](./examples/02-dns-resolution-failure.md): CoreDNS `Corefile` ConfigMap misconfiguration causing intermittent `SERVFAIL` for an internal Service.
    - [`examples/03-cascading-failure-retry-storm.md`](./examples/03-cascading-failure-retry-storm.md): pure cascade from an upstream DB query-plan slowdown; no rollout in window.
    - [`examples/04-deploy-correlator-serialization.md`](./examples/04-deploy-correlator-serialization.md): pure deploy-correlator regression (serialization change breaks downstream parsers).
    
    **Escalation cases** (exercise the FAILURE_MODES.md rules):
    
    - [`examples/05-outside-reference-paths-third-party-rate-limit.md`](./examples/05-outside-reference-paths-third-party-rate-limit.md): a third-party API rate-limits a workload; the methodology escalates rather than force-fitting one of the four paths (M1).
    - [`examples/06-ambiguous-t0-slow-burn.md`](./examples/06-ambiguous-t0-slow-burn.md): slow-burn memory leak where T0 is genuinely ambiguous; escalation (M2) recommends re-running with a widened window.
    - [`examples/07-blast-radius-asymmetric-revert.md`](./examples/07-blast-radius-asymmetric-revert.md): a rollout bundling six unrelated changes; `rollout undo` is the top mitigation but escalates (M3) because the rollback blast radius exceeds the incident.
    - [`examples/08-deploy-correlator-confirmation-bias.md`](./examples/08-deploy-correlator-confirmation-bias.md): a rollout and an RBAC change collide in time; the methodology rejects the deploy-correlator classification (M4 guard) because the rollout diff does not touch the failing surface.
    
    **Edge / boundary cases**:
    
    - [`examples/09-zero-changes-external-cert-expiry.md`](./examples/09-zero-changes-external-cert-expiry.md): zero changes in window, failure is an external partner TLS certificate expiry seen from a cluster workload.
    - [`examples/10-multi-region-asymmetry.md`](./examples/10-multi-region-asymmetry.md): same image deployed to two clusters/regions, one fails; the methodology surfaces the per-region asymmetry as a first-class signal.
    - [`examples/11-capacity-bound-organic-growth.md`](./examples/11-capacity-bound-organic-growth.md): organic traffic growth saturates capacity; the methodology recommends scaling (HPA) instead of `rollout undo`.
    
    The examples mirror the seven methodology steps so contributors can see the methodology in motion, not just described.
    
    ## Replay tests
    
    Every example has a replay test in `tests/` that runs the methodology against committed fixtures, with no external credentials (no live cluster needed). Run from the skill directory:
    
    ```bash
    for t in tests/replay_*.py; do python "$t" || exit 1; done
    ```
    
    The 11 tests cover the four reference paths, the FAILURE_MODES.md escalation rules (M1, M2, M3, M4), and the edge cases (zero changes, multi-region asymmetry, capacity saturation). Tests exit non-zero if the methodology produces the wrong classification, mitigation, or escalation against known-good fixtures. See [`tests/README.md`](./tests/README.md) for the fixture schema and how to add a new replay test.
    
    ## Failure modes
    
    This skill is wrong in predictable ways. Read [`FAILURE_MODES.md`](./FAILURE_MODES.md) before relying on it for production triage. Highlights:
    
    - The four reference paths cover most but not all Kubernetes incidents; novel failure shapes get force-fit if the agent does not check step 4 carefully.
    - Anchoring on the wrong T0 produces a confidently wrong change-surface bisection.
    - The mitigation recommendation is not a substitute for a human approver on changes with broad blast radius.
    
    ## Anyshift integration (opt-in)
    
    The methodology above runs end-to-end with whatever telemetry, rollout/event source, and RBAC audit log you already have for your cluster (kubectl, the Kubernetes events API, kube-state-metrics, Prometheus). No Anyshift dependency.
    
    The Anyshift MCP can act as a context primer for step 2 (change surface) by exposing a versioned resource graph that links rollouts, RBAC changes, and cluster/infrastructure changes to the specific Kubernetes resources implicated in the incident. See the per-skill README for the measured "with vs without" delta on the OOM and DNS examples (published once the integration has been exercised against the replay tests).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related