Remediation Routing: Violation JSON to Issue Tracker

The moment a scan pipeline emits more than a handful of findings per run, the bottleneck stops being detection and becomes the write path into your issue tracker: create an item for every new violation, update the one that already exists, and close the one that no longer reproduces — all without spawning a duplicate every night. This guide is the machine-integration layer of the Enterprise WCAG Audit Architecture & Standards Mapping section: it specifies how a validated violation record becomes a create, update, or close call against a REST API idempotently, using a stable fingerprint as the external dedup key, a severity-to-priority mapping, and an SLA due-date stamp written at creation time.

The failure this page prevents is the one every team hits second. The first pass wires the scanner to POST /issue on each violation and ships. Within a week the backlog holds nine copies of the same missing image-alt, one per nightly run, because nothing correlated the new finding to the issue already tracking it. The fix is not a smarter scanner — it is treating the tracker as a reconciliation target: compute a deterministic fingerprint for each finding, look up whether an open issue already carries that fingerprint, and branch into create, update, or close. Human ownership, Slack routing, and the escalation policy that decides who gets paged live in the process-focused remediation ticket routing guide; this page owns only the API boundary and the data model behind it.

Prerequisites & Environment Parity

Idempotent routing is only reliable when the record feeding it is already clean and stably shaped. Lock these before writing a single API call:

  • A schema-valid violation record. Every finding must pass the JSON Schema validation for accessibility data contract before it reaches the router. A truncated payload with a missing nodes[].target silently changes the fingerprint and manufactures a duplicate, so validation is a hard precondition, not a nicety.
  • A deduplicated, categorized finding stream. The router consumes the output of the error categorization and triage pipelines, which already collapse the same selector seen across routes and attach a component owner. Route categorized findings, not raw axe.run() output, or you fingerprint noise.
  • requests==2.32.* (or an equivalent HTTP client) with a pinned version and a retry-capable session. The examples below use requests with urllib3 retry, matching the rest of the pipeline’s Python idioms.
  • A service account per tracker with a scoped API token — a Jira Cloud API token bound to a bot user, or a GitHub fine-grained PAT / App installation token limited to issues:write on the target repos. Never route under a human’s credentials; the audit trail must attribute automated writes to the automation.
  • A committed severity taxonomy. The tier definitions and SLA windows the router stamps come from a single source, defined in severity tiers and SLA tracking for accessibility violations. Import that table; do not re-derive priorities inline per tracker.

Treat the fingerprint algorithm itself as frozen infrastructure. Once issues exist in production carrying a fingerprint, changing how it is computed orphans every one of them — the next run will not find them and will re-create the whole backlog. Version the algorithm explicitly and migrate deliberately.

Conceptual Model: The Tracker as a Reconciliation Target

The core idea is to stop thinking of ticket creation as an event and start thinking of it as a reconcile. Each run produces a set of currently reproducing findings. The tracker holds a set of currently open automated issues. Routing is the diff between those two sets, keyed on a fingerprint that is identical across runs for the same underlying defect.

The fingerprint is the external dedup key — the one field that makes the whole system idempotent. It must be stable across runs (the same defect yields the same value tonight and next week) and specific enough that two genuinely different defects never collide. A robust fingerprint hashes the durable identity of a finding: the rule id, the WCAG success criterion, a normalized page identifier, and a structural locator for the element — not a volatile one. Hashing a raw CSS selector like div:nth-child(7) > button is a trap: it churns on every unrelated markup edit and forks a new issue for an unchanged defect. Prefer a stable target — a data-testid, an ARIA landmark path, or the component id attached during triage — and normalize the URL to a route template (/products/{id}) so the same defect on a thousand product pages fingerprints once, not a thousand times.

With a fingerprint in hand, each finding drives a three-way branch. Look up an existing open issue carrying that fingerprint. If none exists, create one and stamp its SLA due date from the severity tier. If one exists, update it — refresh the occurrence count, last-seen timestamp, and affected-route list — without disturbing human-authored comments or the assignee. Separately, any open automated issue whose fingerprint did not appear in the current run is a candidate to close: the defect no longer reproduces. The flow below traces one finding from normalized record to the stamped, reconciled issue.

Reconciling a violation record into an idempotent tracker issue Left to right: a normalized finding enters, a fingerprint is computed from rule id, success criterion, route template and a stable locator, then a lookup queries the tracker for an open issue carrying that fingerprint. A decision on issue state fans out to three terminals: create and stamp the SLA due date when no issue exists, update the occurrence count when one exists, and close the issue when its fingerprint is absent from the current run. none exists absent Normalized finding Fingerprint Lookup Create issue Update issue Close issue Stamp SLA due date schema-valid record stable dedup key by fingerprint new defect refresh count no longer reproduces from severity tier

Three properties make this safe to run unattended. It is idempotent: re-running the same scan produces no new issues because every finding resolves to an existing fingerprint. It is convergent: the tracker’s open set trends toward exactly the set of reproducing defects, run after run. And it is non-destructive: updates and closes touch only automation-owned fields and status, never a human’s triage notes, so an engineer’s investigation survives the next reconcile.

Step-by-Step Implementation

The sequence below builds a tracker-agnostic router. Steps 1–3 are shared; steps 4–5 bind it to concrete APIs, each with its own dedicated guide.

1. Compute a stable fingerprint

Hash the durable identity of the finding. Normalize the route to a template and prefer a structural locator so unrelated markup edits do not fork a new issue.

import hashlib
import re

FINGERPRINT_VERSION = "v2"  # frozen; bumping it orphans every existing issue

def route_template(url: str) -> str:
    """Collapse volatile path segments so one defect fingerprints once."""
    path = re.sub(r"https?://[^/]+", "", url)          # drop scheme + host
    path = re.sub(r"/\d+", "/{id}", path)              # numeric ids
    path = re.sub(r"/[0-9a-f]{8}-[0-9a-f-]{27,}", "/{uuid}", path)
    return path.rstrip("/") or "/"

def fingerprint(finding: dict) -> str:
    """Stable external dedup key for one categorized violation."""
    # `stable_target` is attached during triage: a data-testid, landmark
    # path or component id — never a raw nth-child CSS selector.
    parts = [
        FINGERPRINT_VERSION,
        finding["rule_id"],                 # e.g. "color-contrast"
        finding["wcag_sc"],                 # e.g. "1.4.3"
        route_template(finding["url"]),
        finding["stable_target"],
    ]
    digest = hashlib.sha256("".join(parts).encode()).hexdigest()
    return f"a11y-fp-{digest[:16]}"          # short, label-safe

2. Model the routing decision independently of any tracker

Keep the create/update/close decision pure so it is unit-testable against fixtures without touching the network. It takes this run’s findings and the tracker’s current open set, both keyed by fingerprint, and returns a plan.

from dataclasses import dataclass, field

@dataclass
class RoutePlan:
    to_create: list[dict] = field(default_factory=list)
    to_update: list[tuple[str, dict]] = field(default_factory=list)  # (issue_key, finding)
    to_close:  list[str]  = field(default_factory=list)              # issue_keys

def reconcile(findings: dict[str, dict], open_issues: dict[str, str]) -> RoutePlan:
    """`findings`: fingerprint -> finding. `open_issues`: fingerprint -> issue_key."""
    plan = RoutePlan()
    seen = set(findings) & set(open_issues)
    for fp, finding in findings.items():
        if fp in seen:
            plan.to_update.append((open_issues[fp], finding))
        else:
            plan.to_create.append(finding)          # brand-new defect
    for fp, issue_key in open_issues.items():
        if fp not in findings:
            plan.to_close.append(issue_key)         # no longer reproduces
    return plan

3. Map severity to tracker priority and stamp the SLA due date

Priority and the due date both derive from the severity tier, computed once from axe impact plus WCAG level. Import the tier table from the severity guide rather than hardcoding a per-tracker mapping.

from datetime import datetime, timedelta, timezone

# Tier -> (tracker priority name, SLA window in days). Single source of truth.
TIER = {
    "T1-critical": ("Highest", 3),
    "T2-serious":  ("High",    10),
    "T3-moderate": ("Medium",  30),
    "T4-minor":    ("Low",     90),
}

def sla_due(tier: str, first_seen: datetime | None = None) -> str:
    first_seen = first_seen or datetime.now(timezone.utc)
    _, window_days = TIER[tier]
    return (first_seen + timedelta(days=window_days)).isoformat()

4. Bind create-or-update to Jira

The Jira path uses a JQL search on a fingerprint label to find the existing issue, then either POST /rest/api/3/issue or PUT to update it. The full create-issue payload, ADF description body, and label-based JQL lookup are in auto-creating Jira issues from violation JSON.

5. Bind create-or-update-close to GitHub

The GitHub path embeds the fingerprint as a hidden marker in the issue body and searches for it, assigning labels and owners from CODEOWNERS, then reopening or closing via the state field. The distinct payload, search query, and reopen logic are in auto-creating GitHub issues from violation JSON.

Configuration Reference

The router maps each violation record field onto a tracker field. Names below are the router’s internal contract; the two tracker guides show how each lands in Jira and GitHub specifically.

Field Type Default Description
fingerprint string computed External dedup key; stored as a Jira label or hidden GitHub body marker. Never displayed as prose.
rule_id string required axe rule id (image-alt, color-contrast, target-size) — the first fingerprint component and the issue summary prefix.
wcag_sc string required Success criterion (1.1.1, 1.4.3, 2.5.8); drives level tagging and part of the fingerprint.
stable_target string required Structural locator attached during triage; the volatile CSS selector is stored in the body but excluded from the fingerprint.
route_template string computed Normalized URL (/products/{id}) so one defect across many pages fingerprints once.
severity_tier enum T3-moderate Tier from impact + level; maps to priority and the SLA window.
priority string from tier Tracker priority name (Highest/High/Medium/Low), resolved via the tier table.
sla_due ISO 8601 from tier Due date stamped at creation; unchanged on later updates.
occurrence_count integer 1 Times the fingerprint has appeared across runs; incremented on update, never on create.
affected_routes array [url] Concrete URLs where the defect currently reproduces; refreshed each update.
last_seen ISO 8601 run time Timestamp of the most recent run that reproduced the defect; drives the close sweep.

Verification & Testing

Prove idempotency before proving anything else: the same input must produce zero writes on the second pass. The pure reconcile function makes this a fast unit test with no network.

def test_second_run_creates_nothing():
    finding = {"rule_id": "link-name", "wcag_sc": "2.4.4",
               "url": "https://app.example.com/cart/482",
               "stable_target": "[data-testid='mini-cart']"}
    fp = fingerprint(finding)

    # First run: nothing open yet -> one create, no updates, no closes.
    first = reconcile({fp: finding}, open_issues={})
    assert len(first.to_create) == 1 and not first.to_update and not first.to_close

    # Second run: the issue now exists under the same fingerprint.
    second = reconcile({fp: finding}, open_issues={fp: "A11Y-31"})
    assert not second.to_create          # <-- idempotent: no duplicate
    assert second.to_update == [("A11Y-31", finding)]
    assert not second.to_close

def test_disappeared_finding_is_closed():
    plan = reconcile(findings={}, open_issues={"a11y-fp-abc": "A11Y-9"})
    assert plan.to_close == ["A11Y-9"] and not plan.to_create

In CI, run the router against a staging tracker project with a seeded fixture, execute it twice against the same scan output, and assert the issue count is identical after both passes. A second-pass count that grew means the fingerprint is unstable — usually because a raw selector leaked into it, or the route template failed to collapse an id. Diff the fingerprints between the two runs to find the field that churned.

Failure Modes & Troubleshooting

Duplicate issues on every run. Symptom: the backlog gains one copy of each defect per nightly scan. Root cause: the fingerprint is not actually stable — most often it hashes a volatile nth-child selector or an un-normalized URL with an embedded id. Fix: fingerprint the structural stable_target and the route template only, and add the idempotency unit test above to CI so an unstable key fails the build.

Orphaned backlog after a deploy. Symptom: overnight the tracker doubles — every existing issue is re-created alongside a still-open twin. Root cause: FINGERPRINT_VERSION or the algorithm changed, so lookups miss every existing issue. Fix: freeze the algorithm; when a change is unavoidable, run a one-time migration that re-labels open issues with the new fingerprint before the next scan.

API rate-limit rejection mid-run. Symptom: the first few hundred writes succeed, then 429 responses cascade and the run aborts with a partial reconcile. Root cause: unbatched, unthrottled writes against a per-account quota (Jira Cloud and GitHub both meter aggressively). Fix: use a retry session honoring Retry-After, batch lookups with a single JQL/search query per run instead of one lookup per finding, and cap write concurrency. Because the reconcile is idempotent, a run that aborts halfway is safe to resume — the completed writes are simply no-ops next time.

Closed issue silently reopened as new. Symptom: a defect fixed and closed last week reappears as a fresh issue rather than reopening the old one. Root cause: the lookup queried only open issues, so a still-valid fingerprint on a closed issue was invisible and the router created a duplicate. Fix: scope the reopen path to search recently closed issues by fingerprint too, and reopen rather than create when a flapping defect returns within a defined window.

Human triage notes clobbered on update. Symptom: an engineer’s investigation comment or reassignment vanishes after the next scan. Root cause: the update path rewrote the whole issue instead of patching only automation-owned fields. Fix: on update, touch only occurrence_count, last_seen, affected_routes, and the machine-generated body block; never overwrite the assignee, human comments, or manually set priority.

Frequently Asked Questions

Why does my pipeline create a duplicate ticket for the same violation every night?

Nothing is correlating tonight’s finding to the issue already tracking it. Compute a deterministic fingerprint from the rule id, success criterion, route template, and a stable locator, store it on the issue, and look it up before deciding whether to create. If duplicates persist, the fingerprint is churning — almost always because it hashes a volatile CSS selector or a URL with an un-normalized id.

How should I handle the tracker API rate-limiting my scan's write burst?

Batch and throttle. Replace per-finding lookups with a single search query per run, use an HTTP session that retries on the tracker’s Retry-After header, and cap concurrent writes. Because the reconcile is idempotent, a run that hits the limit and aborts partway through is safe to re-run — already-written issues resolve to updates that are effectively no-ops.

Should a violation that returns after being fixed reopen the old issue or create a new one?

Reopen the old one when it returns within a reasonable window, so the issue’s history and prior remediation context stay attached. Extend the fingerprint lookup to also search recently closed issues; if a match is found, flip its state back to open and refresh the occurrence count instead of minting a fresh ticket. Beyond the window, treat it as new so stale history does not mislead.

How do I turn an axe impact level into a tracker priority?

Do not map impact straight to priority — combine it with the WCAG conformance level into a severity tier first, then map the tier to the tracker’s priority names and its SLA window in one table. A serious-impact failure of a Level A criterion outranks a serious-impact failure of a AAA one, and only the tier model captures that. The tier definitions live in the severity tiers and SLA tracking guide.

Where does the decision of who gets assigned the ticket belong?

Not here. This integration owns the API boundary — payload construction, the dedup key, and the SLA stamp. Which human or team owns the fix, how it escalates, and which Slack channel is notified are process concerns covered by the compliance baseline’s remediation ticket routing guide, which this integration feeds.