Burning Down an Accessibility Backlog Across Quarters
A backlog of 4,812 findings is a multi-quarter programme whether or not anyone plans it as one. This guide is part of Accessibility Debt Triage & Prioritization, and it covers the measurement that makes such a programme survivable: two curves computed separately from stored run history, a quarter target derived from throughput the team has actually demonstrated, an exit criterion specific enough to be met, and a recurring report short enough to be read.
Root Cause
The default chart every dashboard produces is total open violations over time, and it is the wrong chart because it sums two populations that behave differently and mean different things. The first is the pre-existing backlog: a fixed cohort of findings that existed when the gate went live, which should shrink monotonically and never grow. The second is newly introduced findings: defects merged after the gate went live, which should be zero at all times, because preventing them is the entire purpose of the gate.
Adding those two together destroys the only signal that matters. Take a real week from the middle of a programme: the total moved from 2,266 to 2,254. Twelve fewer findings, a small green arrow on the dashboard, an uneventful weekly review. What actually happened was 74 findings fixed and 62 findings newly introduced — the gate had been silently skipped on a release branch for eleven days, and the team was running to stand still. On the combined curve that is indistinguishable from a slow week of remediation. On two curves it is unmissable: the remaining line drops normally and the introduced line lifts off zero, which is an incident, not a trend.
The second failure of the single-total view is planning. A total that includes new arrivals cannot support a forecast, because the forecast needs a rate: findings closed per week, measured over enough weeks to include a holiday, an incident and a release freeze. Teams that skip the measurement set a target by working backwards from a date somebody wants — “clear it by year end” — and the target is missed in the first quarter, at which point the programme’s credibility is spent. Throughput is measurable from the same run history that produces the curves, and the honest number it produces is usually about half of what was originally promised.
Configuration
Everything here derives from one append-only artifact: a run-history file that records which finding fingerprints were open at each scan. One line per run keeps the file small — 4,812 twelve-character ids is roughly 60 KB per run, and a weekly cadence is under 4 MB a year, which is comfortable to commit.
// a11y/history/record.mjs
// Usage: node a11y/history/record.mjs triage/findings.ndjson >> a11y/history/runs.ndjson
import { readFileSync } from 'node:fs';
const rows = readFileSync(process.argv[2], 'utf8').trim().split('\n').map(JSON.parse);
const ids = rows.map((r) => r.id);
process.stdout.write(
JSON.stringify({
runId: process.env.GITHUB_RUN_ID ?? `local-${Date.now()}`,
scannedAt: new Date().toISOString().slice(0, 10),
routeCount: new Set(rows.map((r) => r.route)).size, // coverage, tracked separately
ruleSetVersion: process.env.AXE_VERSION ?? 'unknown',
openIds: ids,
// Blocking subset is reported on its own: it is the number leadership acts on.
blockingIds: rows.filter((r) => r.factors?.harm === 'blocksTask').map((r) => r.id),
}) + '\n',
);
The two curves come from cohort membership, not from counting. Fix the gate-live date once, treat every id present in the first run on or after that date as the baseline cohort, and classify everything else by when it first appeared. A baseline id that disappears and later returns is a third category — a regression of already-fixed work — and it belongs on the introduced curve, because it is a failure of prevention rather than a remnant of history.
// a11y/history/curves.mjs
// Usage: node a11y/history/curves.mjs a11y/history/runs.ndjson 2026-01-05
import { readFileSync } from 'node:fs';
const runs = readFileSync(process.argv[2], 'utf8').trim().split('\n').map(JSON.parse);
const gateLive = process.argv[3];
runs.sort((a, b) => a.scannedAt.localeCompare(b.scannedAt));
const firstAfterGate = runs.find((r) => r.scannedAt >= gateLive);
if (!firstAfterGate) throw new Error(`no run on or after the gate-live date ${gateLive}`);
const baseline = new Set(firstAfterGate.openIds);
// Ids seen in any earlier run of this cohort, used to detect reappearance.
const everClosed = new Set();
let previousOpen = new Set(firstAfterGate.openIds);
const series = [];
for (const run of runs.filter((r) => r.scannedAt >= gateLive)) {
const open = new Set(run.openIds);
for (const id of previousOpen) if (!open.has(id)) everClosed.add(id);
let remaining = 0; // baseline cohort still open: must fall
let introduced = 0; // never in the baseline: must stay at zero
let regressed = 0; // baseline, closed once, open again: also a prevention failure
for (const id of open) {
if (!baseline.has(id)) introduced += 1;
else if (everClosed.has(id)) regressed += 1;
else remaining += 1;
}
series.push({
date: run.scannedAt,
remaining,
introduced: introduced + regressed,
blocking: run.blockingIds.length,
routeCount: run.routeCount, // coverage growth is never debt growth
});
previousOpen = open;
}
console.log(JSON.stringify(series, null, 2));
routeCount travels with every point for one reason: adding routes to the scan manifest raises every count without anything getting worse. When the remaining curve jumps, the first question is whether coverage changed, and having the number in the series answers it without a git archaeology session. Report coverage as its own line on the chart rather than normalising it away — a backlog that grew because the scan started looking harder is good news reported badly.
Cohort membership is worth spelling out because it is the part teams implement loosely and then cannot explain. A fingerprint’s first appearance decides its cohort permanently. Nothing moves from introduced to baseline, ever — otherwise a quarter of bad prevention quietly becomes historical debt, which is the most comfortable and least honest accounting available.
Validation
The target has to be derived, not chosen. Measure closures per week from the same history file, take the median rather than the mean so one heroic sprint does not set the plan, and discount for the weeks that reliably disappear — holidays, incident response, release freezes. Eight weeks is the minimum useful window; a quarter is better.
// a11y/history/target.mjs
// Usage: node a11y/history/target.mjs a11y/history/runs.ndjson 2026-01-05
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const series = JSON.parse(
execFileSync('node', ['a11y/history/curves.mjs', process.argv[2], process.argv[3]]),
);
// Weekly closures = fall in the remaining curve between consecutive runs.
const closures = [];
for (let i = 1; i < series.length; i += 1) {
closures.push(Math.max(series[i - 1].remaining - series[i].remaining, 0));
}
const window = closures.slice(-8);
const sorted = [...window].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
// Median, not mean: one heroic codemod week must not set the plan.
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
const WEEKS_PER_QUARTER = 13;
const AVAILABILITY = 0.8; // holidays, incidents and freezes measured over a year
const perQuarter = Math.floor(median * WEEKS_PER_QUARTER * AVAILABILITY);
const remaining = series.at(-1).remaining;
console.log(`measured weekly closures: ${window.join(', ')}`);
console.log(`median: ${median}/week → quarter target: ${perQuarter}`);
console.log(`remaining: ${remaining} → quarters to exit: ${Math.ceil(remaining / perQuarter)}`);
if (series.at(-1).introduced > 0) {
console.log(`WARNING: ${series.at(-1).introduced} introduced findings open — fix the gate first`);
}
Run against a real eight-week window, the output is deflating and useful in equal measure:
measured weekly closures: 96, 74, 121, 0, 58, 143, 88, 67
median: 81/week → quarter target: 842
remaining: 4812 → quarters to exit: 6
An exit criterion has to be a set of conditions a run can evaluate, not a sentiment. Three conditions, all of which must hold on two consecutive scheduled runs before the backlog programme closes: zero remaining baseline findings that block task completion on primary-journey routes; the introduced curve at zero for two full quarters; and every remaining P3 finding either closed or carrying a documented, dated deferral. Note what is absent — “zero findings”. Total zero is not an exit criterion for any real application, because the scan surface keeps growing and incomplete results keep arriving; a programme whose success condition is unreachable never gets to declare success and quietly loses its funding instead.
// a11y/history/exit-criterion.mjs — exits 0 when the backlog programme can close.
import { readFileSync } from 'node:fs';
const series = JSON.parse(readFileSync('a11y/history/series.json', 'utf8'));
const queue = readFileSync('triage/queue.ndjson', 'utf8').trim().split('\n').map(JSON.parse);
const recent = series.slice(-2); // two consecutive scheduled runs
const twoQuarters = series.slice(-26); // twenty-six weekly runs
const blockingOnPrimary = queue.filter(
(r) => r.factors.harm === 'blocksTask' && r.factors.tier === 'primaryJourney',
);
const checks = {
noBlockingOnPrimaryJourneys:
blockingOnPrimary.length === 0 && recent.every((p) => p.blocking === 0),
preventionHeldTwoQuarters: twoQuarters.every((p) => p.introduced === 0),
deferralsDocumented: queue
.filter((r) => r.band === 'P3')
.every((r) => r.deferredUntil && r.deferredReason),
};
for (const [name, ok] of Object.entries(checks)) console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`);
process.exitCode = Object.values(checks).every(Boolean) ? 0 : 1;
Edge Cases and Conditional Guards
- A missed scheduled run. A cancelled runner or an expired token leaves a gap in the history, and the closure calculation for the following week then covers fourteen days and doubles that week’s apparent throughput. Compute closures per elapsed day and multiply by seven rather than trusting the run index, and drop any interval longer than twenty-one days from the throughput window entirely.
- A rule-set upgrade mid-programme. Upgrading axe-core adds rules, retires others and occasionally re-scopes an existing rule, which mints new fingerprints for defects that were always there. Those must not land on the introduced curve. Record
ruleSetVersionper run, and on any change re-baseline only the rules whose ids are new: their findings join the baseline cohort with a note, and the report shows the re-baseline as an annotation on the chart. - A route removed from the product. Deleting a page closes its findings, which is legitimate progress and also the easiest way to fake a burndown. Track
routeCountalongside the curves and require the weekly report to state closures split by cause — fixed, deleted, or re-scoped — because a quarter target met by deleting an admin surface teaches the programme nothing about its own capacity.
Pipeline Impact
The history, curve and target scripts run in the scheduled triage job, after the queue is regenerated and before the report is posted. None of them fails the build on a number: a rising remaining curve is a planning problem, not a broken pipeline. Two conditions do warrant a non-zero exit — a gap in the history file that makes the curves unreliable, and a non-zero introduced count, because that means the pull-request gate let something through and the leak is worth more attention than the backlog.
The recurring report is four lines, posted to the same channel every week and to the quarterly review verbatim: remaining, introduced, blocking-on-primary-journeys, and quarters-to-exit at current measured throughput. Publish it as a job summary and a chat message, and keep the series JSON as a build artifact so the violation-trend view renders from the same numbers the report quotes — two systems computing the backlog differently is how a review turns into an argument about tooling.
- name: Compute curves, target and the weekly report
run: |
node a11y/history/record.mjs triage/findings.ndjson >> a11y/history/runs.ndjson
node a11y/history/curves.mjs a11y/history/runs.ndjson 2026-01-05 \
> a11y/history/series.json
node a11y/history/target.mjs a11y/history/runs.ndjson 2026-01-05 \
| tee -a "$GITHUB_STEP_SUMMARY"
- name: Fail if the gate leaked new findings this week
run: |
introduced=$(jq '.[-1].introduced' a11y/history/series.json)
test "$introduced" -eq 0 || {
echo "::error::$introduced new findings introduced since the gate went live"
exit 1 # a prevention failure outranks any backlog number
}
Per-team throughput is worth computing separately once the buckets from ownership routing exist, because a single organisation-wide median hides the fact that one team is clearing 60 findings a week and four are clearing none. A quarter target allocated proportionally to measured per-team throughput is defensible in a planning meeting; one allocated by bucket size is a demand that the team with the largest bucket work fastest, which is exactly backwards.
Common Pitfalls
- Charting one combined total, which makes a week of 74 fixes and 62 regressions look like ordinary slow progress.
- Setting the quarter target from a desired end date rather than from measured throughput, which spends the programme’s credibility in its first quarter.
- Using the mean of weekly closures, so one large codemod week sets a target no ordinary week can meet.
- Letting introduced findings age into the baseline cohort, which converts a prevention failure into comfortable historical debt.
- Counting a route deletion as a fix without labelling it, which lets a quarter target be met without any accessibility work happening.
- Defining the exit criterion as zero findings, which is unreachable on a growing product and guarantees the programme never declares success.
- Ignoring
ruleSetVersionchanges, so an axe-core upgrade appears on the introduced curve as a gate failure and triggers an incident review of a working gate.
FAQ
Why weekly scans rather than daily? Weekly matches the cadence at which the numbers change and at which anyone acts on them, and it keeps the history file small enough to commit and diff. Daily full-coverage scans of 400 routes cost real runner minutes and produce six points of noise for every point of signal, since fixes land in batches at sprint boundaries. Where a faster signal is wanted, get it from the pull-request gate — that is a per-commit check already — and leave the full scan on a weekly rhythm.
What if the remaining curve flattens for a whole quarter? Read the throughput numbers before the priority model. Flat usually means capacity went elsewhere — an incident, a migration, a reorganisation — and the honest response is to restate the exit estimate with the new measured median rather than to keep quoting the old plan. If capacity was genuinely spent on accessibility and the curve is still flat, the queue is stuck on high-cost groups, and the fix is to schedule the design decisions those groups need rather than to ask for more effort.
Should the two curves be reported to leadership separately? Yes, and in that order: prevention first, then remediation. The introduced count is a yes-or-no question about whether the gate works, it needs no context to interpret, and a non-zero value is the only number in the report that requires action this week. Remaining is a trend that only means something against the quarter target, so report it as “1,452 remaining against a target of 842 closed this quarter, five quarters to exit” — a number, its target and a forecast, which is the shape an engineering leader can actually plan against.
Related
- Accessibility Debt Triage & Prioritization — the triage model that produces the queue these curves measure.
- Assigning Violation Ownership with CODEOWNERS — per-team buckets, which is what per-team throughput is computed from.
- CI/CD Integration & Automated Quality Gating — the gate whose job is to keep the introduced curve flat at zero.