Fix outcomes
Opening an eval-verified PR proves a fix works before it ships. Fix outcomes closes the loop after it ships: when a Neens-opened fix PR is merged, Neens records the deploy, watches the fixed failure in real production traffic for a window, and reports whether the failure actually went down — per agent, per merged fix.
This is the difference between a dashboard number and a business outcome. “Failures shown” is a chart. “Failure volume is down 60% across 8 merged fixes, with a median cluster-to-merge time of 2 days” is a result you can take to a stakeholder — the public close-out of the Neens failure → fix → proof loop.
At a glance
| Where | The Outcomes page under Fix |
| Key API | GET /remediations/efficacy-report · POST /remediations/items/{id}/record-merge · POST /api/webhooks/github |
| MCP tool | record_fix_merge (report a merge from a coding agent) |
| What it measures | Real production volume of the fixed failure mode, in the window before vs after the deploy, whether the proof-eval gate still holds, and — beside it — whether each active business KPI actually moved |
| Verdict | Each merged fix auto-transitions to verified, regressed, or inconclusive |
| Needs | A merged fix PR correlated to a remediation (via the GitHub webhook, the record-merge API, or MCP) |
How the loop closes
A fix PR merges
Neens learns the PR was merged one of three ways (below) and correlates the merged PR back to its remediation (by PR URL / fix run).
Neens records the deploy and starts a watch
On a merged PR, Neens writes a deploy event and opens a close-out watch for the remediation’s failure mode.
The window elapses
After the measurement window (7 days), a scheduled sweep measures the failure mode’s real production volume in the equal-length windows before and after the deploy, and re-checks that the remediation’s proof-eval gate still holds.
The remediation gets a verdict
The remediation auto-transitions based on what actually happened in production — verified, regressed, or inconclusive (see How the verdict is decided) — and the Outcomes report updates.
Tell Neens a fix merged
Neens needs to know when a PR merges. Pick whichever path fits how you ship.
Configure your repository or GitHub App webhook to POST pull_request events to:
https://<neens-host>/api/webhooks/githubSet a shared secret on the webhook; your operator configures Neens with the same value. Neens
verifies GitHub’s X-Hub-Signature-256 HMAC header against that secret on every delivery and
ignores anything that doesn’t match — so an unsigned or mis-signed call is never trusted. On a
merged PR event Neens correlates the PR to its remediation and starts the watch automatically.
Read the Outcomes report
Open Fix → Outcomes for a per-agent rollup of every merged fix and what it did in production.
The same data is served by GET /remediations/efficacy-report.
The KPIs
The headline tiles answer “did shipping fixes make the product better?”:
- Fixes shipped / verified / regressed — how many merged fixes have a verdict, and how many held up in production vs. came back.
- Failure-volume reduction % — total volume of the fixed failure modes before vs after their deploys, as one headline percentage across all merged fixes.
- Median MTTR — the median time from a failure first being seen (its cluster’s first-seen timestamp) to the fix merging. This is your cluster-to-merge time: how fast the loop actually turns.
Before vs after, per fix
Each merged fix shows its failure volume before the deploy next to its volume after, and the per-fix reduction. A fix that drove its failure mode from 40 occurrences to 6 reads as an 85% drop and a verified verdict; a fix whose volume didn’t move (or whose gate broke) reads as regressed.
What verified / regressed / inconclusive mean
- Verified — production volume of the failure mode dropped and the proof-eval gate still holds. The fix did what it claimed.
- Regressed — volume didn’t drop, or a proof gate that used to pass now fails. The failure is still happening (or came back) despite the merge — worth reopening.
- Inconclusive — too little traffic to call it. When the before-window volume is too low to form a baseline, there is no reduction to measure against, so Neens won’t manufacture a verdict from noise. (An after-window spike is a regression, not an inconclusive.)
The measurement is over real production traces, not the pre-merge eval run. Post-merge efficacy and the pre-prod eval that gated the PR are complementary: the pre-prod gate proves the fix works on the golden dataset before merge; the outcome report confirms it held on live traffic after merge.
Example GET /remediations/efficacy-report response
{
"window": { "days": 7 },
"summary": {
"fixesShipped": 8,
"fixesVerified": 6,
"fixesRegressed": 1,
"fixesPending": 1,
"fixesInconclusive": 0,
"totalVolumeBefore": 240,
"totalVolumeAfter": 96,
"volumeReductionPct": 60.0,
"mttrMedianSeconds": 172800,
"mttrMedianDays": 2.0,
"mttrCount": 7
},
"closeouts": [
{
"id": "co-…",
"remediationId": "rem-…",
"remediationTitle": "Guardrail over-blocks refunds",
"status": "verified",
"prUrl": "https://github.com/acme/agent/pull/42",
"mergedAt": "2026-07-10T00:00:00Z",
"beforeVolume": 40,
"afterVolume": 6,
"reductionPct": 85.0,
"volumeStatus": "improved",
"gatesHeld": true,
"mttrDays": 1.5,
"windowDays": 7
}
],
"trend": [
{ "period": "2026-07", "fixesVerified": 6, "volumeBefore": 240, "volumeAfter": 96, "reductionPct": 60.0 }
]
}summary drives the KPI tiles, closeouts the per-fix before/after table, and trend the
period-over-period chart.
Did the KPI move?
Volume answers “is the failure happening less?” It does not answer “did the number we funded this fix to move actually move?” — and those two come apart all the time: a failure can get rarer while the business metric it was hurting barely budges, or a fix can quietly lift a KPI well beyond the one cluster it targeted. So beside the volume leg, each close-out now carries a KPI leg. For every active business KPI, Neens measures the KPI over the window before the deploy and over the equal window after it, and reports the before→after move.
It measures each KPI at two scopes, so you can tell a local win from a fleet-wide one:
- Cluster — the KPI computed over just the failure cluster this fix targeted. “Did containment recover for the cases this fix was about?”
- Agent — the KPI over the whole agent, unfiltered. “Did the fleet number move, or only this corner of it?”
A fix can read improved at the cluster scope and flat at the agent scope — that is a real, common, honest result (the fix worked where it was aimed, and that corner is a small slice of all traffic), not a contradiction.
The four statuses
Every KPI-delta row lands in one of four statuses, and each renders differently on purpose.
| Status | What it means |
|---|---|
| improved | The KPI moved in the good direction — per the KPI’s own direction — by more than sampling noise. |
| flat | The KPI barely moved: the change is real arithmetic but small, or inside sampling noise. Reported as no meaningful move, not celebrated. |
| regressed | The KPI moved the wrong way, past sampling noise, after the deploy. Worth a look even when the failure’s volume dropped. |
| unknown | Neens could not form an honest delta — too few decided cases either side, no before sample, or the definition changed between the windows. Shown as —, never 0. |
A move inside sampling noise is flagged, not celebrated. For a rate KPI, Neens puts a confidence interval on the delta; if that interval straddles zero, the move is not distinguishable from noise and the row says so — even when the point estimate looks good. A 2-point containment bump over sixty cases is not a win yet; it is a number that will move again next week. Only a move that clears the noise floor reads improved.
unknown is a dash, never a zero. A thin sample, a window with no before data, or a KPI whose
definition changed between the two
windows all read unknown with a stated reason and render as —. The definition change is the
subtle one: the two ends are answering different questions, so Neens draws no delta across it rather
than a movement nobody made. Cost KPIs carry no confidence interval, so their move is reported without
a distinguishable-from-noise claim; a window that was only partly priced reads unknown rather than
a floor-minus-floor number nobody can defend.
A worked example
The remediation “Guardrail over-blocks refunds” was funded because an over-eager guardrail was escalating refund cases a human then had to pick up. After it merged, its close-out shows:
- Volume — the failure mode fell from 40 occurrences to 6 (an 85% drop): the failure is happening far less.
- KPI, cluster scope — containment rate on that cluster’s cases rose 62% → 81%, a distinguishable +19-point move: the cases this fix was about are now handled without a human.
- KPI, agent scope — agent-wide containment moved 87% → 88%, reported flat: the fleet number barely shifted, because this one cluster is a small share of all traffic. Honest, and not oversold.
That is what the KPI leg adds: not just “the failure is rarer”, but “and here is the business number it was costing you, measurably recovered for the cases it touched.”
Today the KPI leg reports beside the volume verdict; it does not override it. The verified / regressed / inconclusive verdict is still decided from failure volume and the proof gate. The KPI move is shown next to it so you can see whether the business number followed the volume — a fix can cut volume without moving the KPI, and that gap is worth knowing. A KPI regression that clearly stands out from noise may in future be allowed to weigh on the verdict; for now it is reported, not enforced.
Example close-out with kpiDeltas (from GET /remediations/efficacy-report)
Each object in closeouts now carries a kpiDeltas array — one row per active KPI per scope. An
empty array ([]) means no KPIs are active, or none applied to this fix. unknown rows are kept in
the array with a reason; they are a recorded fact, never dropped and never shown as 0.
{
"id": "co-9f2a1c",
"remediationId": "rem-4471",
"remediationTitle": "Guardrail over-blocks refunds",
"status": "verified",
"beforeVolume": 40,
"afterVolume": 6,
"reductionPct": 85.0,
"volumeStatus": "improved",
"kpiDeltas": [
{
"kpiId": "kpi-7c1a", "kpiLabel": "Containment rate", "measureKey": "containment_rate",
"scope": "cluster", "kind": "rate",
"before": 0.62, "after": 0.81, "delta": 0.19, "deltaPct": 30.65,
"ciLow": 0.04, "ciHigh": 0.34, "distinguishable": true,
"status": "improved", "unknownReason": null,
"beforeN": 210, "afterN": 240, "targetStatus": "met"
},
{
"kpiId": "kpi-7c1a", "kpiLabel": "Containment rate", "measureKey": "containment_rate",
"scope": "project", "kind": "rate",
"before": 0.87, "after": 0.88, "delta": 0.01, "deltaPct": 1.15,
"ciLow": -0.02, "ciHigh": 0.04, "distinguishable": false,
"status": "flat", "unknownReason": null,
"beforeN": 704, "afterN": 731, "targetStatus": "met"
},
{
"kpiId": "kpi-3d81", "kpiLabel": "Cost per case", "measureKey": "cost_per_case",
"scope": "cluster", "kind": "cost",
"before": 0.51, "after": 0.42, "delta": -0.09, "deltaPct": -17.65,
"ciLow": null, "ciHigh": null, "distinguishable": null,
"status": "improved", "unknownReason": null,
"beforeN": 210, "afterN": 240, "targetStatus": "met"
},
{
"kpiId": "kpi-55c0", "kpiLabel": "Deflection rate", "measureKey": "custom:deflection-rate",
"scope": "cluster", "kind": "rate",
"before": 0.44, "after": 0.51, "delta": null, "deltaPct": null,
"ciLow": null, "ciHigh": null, "distinguishable": null,
"status": "unknown", "unknownReason": "definition_changed",
"beforeN": 96, "afterN": 120, "targetStatus": "unknown"
}
]
}Reading a row:
| Field | What it tells you |
|---|---|
kpiId · kpiLabel · measureKey | Which KPI this row is about. |
scope | cluster (just the fix’s failure cluster) or project (the whole agent). |
kind | rate or cost — the two kinds of KPI a delta is defined for. |
before · after | The KPI over the window before the deploy, and over the equal window after. null (→ —) when that side had nothing to measure. |
delta · deltaPct | The move, in the KPI’s own unit and as a percentage. Both null across a definition change — the two ends aren’t subtractable. |
ciLow · ciHigh | The confidence interval on the delta, for a rate KPI. null for a cost KPI, which has no interval. |
distinguishable | true when the move clears sampling noise, false when it is inside it (the UI says so rather than celebrate). null for a cost KPI or when there is no interval. |
status | improved · flat · regressed · unknown, oriented by the KPI’s own direction. |
unknownReason | Why a row is unknown — too few decided cases, no before sample, the definition changed, and so on. null otherwise. |
beforeN · afterN | The decided denominators either side. A +19-point move over 210 cases is a firmer fact than the same move over 12. |
targetStatus | The after value against the KPI’s committed target: met · missed · unknown. |
The same moves also feed the KPI’s own page: every business KPI lists the fixes that moved it, so you can start from a number that recovered and see which merged fixes recovered it.
How the verdict is decided
For each merged fix, once the watch window has elapsed:
- Measure production volume of the fixed failure mode in the window before the deploy and in the equal window after it.
- Re-check the proof gate — does the remediation’s failure-derived eval gate still pass?
- Decide:
- volume dropped and the gate holds → verified;
- volume did not drop, or a gate broke → regressed;
- before-window volume too low to form a baseline → inconclusive.
The verdict is written back onto the remediation, so its lifecycle (applied → verified /
regressed) reflects what really happened in production — not just that a PR merged.
Troubleshooting
- A merge didn’t start a watch. Neens couldn’t correlate the PR to a remediation. Confirm the PR
URL matches the one Neens stamped when it opened the fix, or record the merge explicitly with
POST /remediations/items/{id}/record-merge. - Webhook deliveries are ignored. The
X-Hub-Signature-256didn’t verify — the secret on the GitHub side doesn’t match the one Neens was configured with. - A fix stays inconclusive. Not enough of the failure mode’s traffic landed in the window. Wait for more production traffic to accumulate before the verdict can be called.