---
name: debug-root-cause
source: https://app.decimal.ai/s/debug-root-cause@1/SKILL.md
source_sha256: 01e0508df10e
---

# Debug to Root Cause

Turns "here's a bug, fix it" into a disciplined investigation. The base model, shown a symptom, tends to jump straight to a plausible-looking patch — often one that suppresses the symptom without touching the cause, and without ever confirming the bug was reproduced or the fix actually works. This skill enforces the debugging method taught for decades (Agans' *Debugging*, the scientific method applied to code): make it fail on demand, look at the real evidence, change one thing at a time, fix the actual cause, then prove it.

## When to Activate

Activate this skill when the user presents a defect and asks for help fixing it:
- "Why is this throwing / crashing / returning the wrong thing?"
- "How would you approach fixing this?"
- Pastes a stack trace, an exception, or a failing assertion
- "This test is red — what's wrong?"
- "It works locally but breaks in production"
- "This is flaky / intermittent — sometimes it fails"

## The Method

### Step 1: Reproduce before you touch anything

You cannot fix what you cannot see fail. Establish a reliable trigger first.

- Find the smallest input, request, or test that makes the bug appear **every time**.
- If it only fails sometimes, that unreliability is itself the first thing to investigate — pin down the condition (specific data, ordering, timing, environment) that flips it.
- If you cannot yet reproduce it, say so and name what you'd collect (exact input, version, config, logs) rather than guessing at a fix in the dark.

A fix proposed against a bug you never reproduced is a guess. Reproduction is the gate.

### Step 2: Read the actual signal — don't reason from the symptom

The place a program *notices* a problem is usually not the place the problem *is*.

- Read the real stack trace / assertion / error message to the frame that raised it. Follow it to the origin, not the surface.
- Look at the concrete values (the actual argument, the actual returned object, the actual diff between expected and got) — not what the code "should" contain.
- Distinguish the symptom (`NoneType has no attribute x`) from the cause (why is it `None` here — which upstream call returned nothing?).

### Step 3: One hypothesis at a time

Form a single, falsifiable guess about the cause, then test *that one thing*.

- State it: "I think X is null because the lookup on line N missed." Then check only that.
- Change **one** variable per experiment. If you edit three things and the bug goes away, you've learned nothing about which one mattered — and may have added two new bugs.
- Narrow by bisection: comment out half, disable half the input, `git bisect` across commits, binary-search the failing range. Each step should halve the suspect space.
- If the hypothesis is wrong, that's a result — discard it and form the next one. Don't keep a disproven theory alive by patching around it.

### Step 4: Fix the root cause, not the symptom

- Ask "why" until you reach the cause that, once fixed, makes the whole class of symptom impossible — not just this instance.
- A `try/except` that swallows the error, a null-guard bolted on where it crashed, a retry around a call that shouldn't fail — these hide the bug, they don't fix it. Flag them as symptom patches, not fixes.
- If a genuine defensive guard is *also* warranted, keep it separate from the actual cause fix and say which is which.

### Step 5: Verify — prove it's gone, prove nothing else broke

- Re-run the exact reproduction from Step 1. The failure must now be absent. "This should fix it" is not verification.
- Check the neighbors: run the surrounding tests / adjacent cases the change could have touched. A fix that breaks a sibling is not done.
- Where practical, add a regression test that reproduces the original failure, so it can't silently come back.

## Output Shape

When walking through a bug, structure the answer as:

```markdown
**Reproduce:** [the minimal trigger; or what's needed to get one]
**Signal:** [what the trace/assertion actually says, traced to its origin]
**Hypothesis:** [single falsifiable cause] → [the one check that confirms/refutes it]
**Root cause:** [the underlying defect, distinguished from the symptom]
**Fix:** [the change at the cause]
**Verify:** [re-run the repro + which neighbors to re-check + regression test]
```

## Example

### Input
```text
Our checkout endpoint occasionally 500s. The log shows:

  TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
    at compute_total (billing.py:88)

  def compute_total(cart):
      total = 0
      for item in cart.items:
          total += item.price * item.qty
      return total + cart.shipping

It doesn't happen on every order. My first thought is to just default shipping to 0.
```

### Expected Response

**Reproduce:** It's intermittent, so first make it deterministic. The trace points at `total + cart.shipping`, so the failing orders are the ones where `cart.shipping is None`. Find one such order (or construct a cart with `shipping=None`) and confirm it 500s every time — that's the reproduction.

**Signal:** The error is at `return total + cart.shipping`, and the operands are `int + NoneType`. `total` is always an int (starts at 0), so the `None` is `cart.shipping`. The crash location is the symptom; the question is *why is shipping ever None?*

**Hypothesis:** Shipping is populated by an upstream step (a rate lookup or a default), and for some orders that step is skipped or returns nothing. Check: where is `cart.shipping` set, and under what branch does it stay unset? One check — trace the field's assignment, don't edit the crash line yet.

**Root cause (likely):** an order path that never assigns `shipping` (e.g., a code created before shipping was required, or a rate-lookup that returns `None` on miss). Defaulting to 0 at the crash site is a **symptom patch** — it stops the 500 but silently under-charges the customer, which is worse than the crash.

**Fix at the cause:** ensure the field is set where orders are built (or make the rate lookup return an explicit value / raise on miss). If unbilled shipping should truly be free, encode that decision upstream, not as a fallback at the arithmetic.

**Verify:** re-run the reproducing order — it must return a correct total, not just avoid the exception. Then re-run the checkout tests, and add a regression test for an order with no shipping set.

## Edge Cases

- **Can't reproduce it at all:** don't fabricate a fix. State the reproduction is missing and list exactly what to capture (input payload, versions, timing, the full trace). An honest "I need X to see it fail" beats a confident wrong patch.
- **Heisenbug (changes when observed):** logging/timing shifts it → suspect a race or uninitialized memory; narrow by making the timing deterministic, not by adding sleeps until it hides.
- **The trace is truncated or swallowed:** the first job is to *un-swallow* it (remove the bare `except`, raise with the cause) so you can read the real signal — that's step 2, not the fix.
- **Fix reveals a second bug:** that's normal — the first fix uncovered a hidden failure. Treat it as a new investigation from Step 1, not a reason to revert.
- **"Just make the test pass":** changing the assertion to match the buggy output is not a fix; the test was reporting a real defect. Fix the code, keep the test honest.

## Evaluation Criteria
A good execution of this skill should:
- [ ] Establish (or explicitly require) a reliable reproduction before proposing a fix
- [ ] Trace the actual error signal to its origin, distinguishing symptom from cause
- [ ] Advance one hypothesis at a time and change one variable per experiment
- [ ] Target the root cause and call out symptom-only patches as such
- [ ] Verify by re-running the reproduction and checking neighbors don't regress
