A/AA/AAA Compliance Level Mapping

The obstacle this page solves is not “which criteria are we scanning” but “what happens when each one fails.” Treat conformance level as a single monolithic target — scan everything, gate on everything — and the AAA criteria that genuinely require structural redesign (enhanced contrast, sign-language alternatives, context-sensitive help) start blocking deployments next to real Level A keyboard traps. Teams respond the only way they can under sprint pressure: they blanket-suppress, and within a few weeks the gate no longer distinguishes a launch-blocking barrier from an aspirational nicety. Compliance level mapping fixes this by making the conformance tier a first-class, machine-readable property of every rule, so the audit orchestrator can filter the active rule set per environment and route each violation deterministically toward either a deployment-blocking gate or a tracked backlog — no ambiguous pass/fail states, no false-positive fatigue.

This guide is part of the broader Enterprise WCAG Audit Architecture & Standards Mapping strategy, and it sits directly downstream of the taxonomy work. Where the WCAG 2.2 vs 3.0 success criteria taxonomy establishes what a criterion means and how the unit of measurement shifts between versions, this page establishes what a criterion’s level does once you have encoded it: how A, AA, and AAA translate into scope, gating, and remediation routing across distributed codebases. The output is a version-aware registry that automated scanners consume the same way in every pipeline.

Prerequisites and Environment Context

Level mapping is configuration code, and a tag mismatch between your registry and the scanning engine is the most common way criteria silently vanish from a run. Pin these before implementing anything below:

  • Python 3.11+ for the orchestration layer. The examples use dataclasses, structured typing, and pydantic 2.x for schema validation of the registry itself.
  • axe-core 4.9+ injected at runtime, whose rule tags (wcag2a, wcag2aa, wcag21aa, wcag22aa, wcag2aaa, best-practice) are the vocabulary your registry must map onto. If you drive the browser through a headless pipeline, keep the engine and browser builds pinned exactly as your axe-core enterprise configuration prescribes, or a minor bump can shift contrast and target-size math enough to move criteria across the pass/fail line.
  • A single source-of-truth registry in YAML or JSON, version-controlled alongside the scanner. One registry, consumed by every environment — never per-team copies that drift.
  • pydantic 2.x (or jsonschema 4.x) so a malformed rule definition fails fast at pipeline init rather than at 3 a.m. mid-scan. This is the same discipline the cousin JSON Schema validation for accessibility data applies to finding output; here you apply it to rule input.

Environment parity matters in one specific way: the conformance target is not a global constant. A public marketing site may gate at AA; a government tenant may be contractually bound to a AAA subset; a preview branch may run A-only for speed. The registry must therefore be filterable per environment rather than baked into the scanner, so the same code enforces different tiers without a rebuild.

Conceptual Model: Level Is a Filter Axis, Not a Severity

The single most important idea on this page is that conformance level and impact severity are orthogonal axes and must never be collapsed into one number. Level answers a legal/scope question — “is this criterion in the conformance target we committed to?” — and takes exactly three values: A, AA, AAA. Severity answers an engineering question — “how badly does this specific instance hurt a user?” — and axe expresses it as minor/moderate/serious/critical. A Level A criterion can produce a minor instance (a redundant skip link) and a Level AAA criterion can produce a serious one. Gate on the wrong axis and you either block deploys on cosmetic A findings or wave through serious AAA regressions.

The registry is the translation layer that keeps the two axes explicit. Every scanner rule id resolves through it to a WCAG criterion id, a level, and an action — the routing decision. The orchestrator loads only the rules whose level is in the environment’s target set, evaluates them, then uses each finding’s action to decide block vs. log vs. backlog. Legacy applications complicate this because a criterion’s number can change across WCAG revisions; the registry preserves a stable internal id and maps historical criteria to current equivalents so old suites keep resolving.

The diagram below shows how a tagged violation is routed by its conformance tier toward either a deployment-blocking gate or the aspirational backlog:

Step-by-Step Implementation

1. Build a version-aware criteria registry

Encode each scanner rule once, tagging it with its WCAG id, conformance level, engine tag, severity ceiling, and routing action. This manifest is the contract every environment reads; nothing downstream hard-codes a level.

# compliance_registry.yaml
rules:
  - id: "color-contrast-minimum"
    wcag_id: "1.4.3"        # Contrast (Minimum)
    level: "AA"
    engine_tag: "wcag2aa"   # matches axe-core's own tag vocabulary
    severity: "critical"
    action: "block"
  - id: "target-size-minimum"
    wcag_id: "2.5.8"        # new in WCAG 2.2
    level: "AA"
    engine_tag: "wcag22aa"
    severity: "serious"
    action: "block"
  - id: "focus-visible-enhanced"
    wcag_id: "2.4.13"       # Focus Appearance
    level: "AAA"
    engine_tag: "wcag2aaa"
    severity: "moderate"
    action: "backlog"       # never blocks; routes to quarterly review

2. Load and validate the registry with strict typing

Query the registry to load rule sets per environment, and validate every record on load so a typo in level or a missing action fails the pipeline immediately instead of silently disabling a criterion. The pydantic model below constrains level and action to their legal values.

import yaml
from typing import List, Literal
from pydantic import BaseModel, Field, ValidationError

class ComplianceRule(BaseModel):
    id: str
    wcag_id: str
    level: Literal["A", "AA", "AAA"]          # anything else fails fast
    engine_tag: str
    severity: Literal["minor", "moderate", "serious", "critical"]
    action: Literal["block", "log", "backlog"]

def load_rules_by_level(registry_path: str, target_levels: List[str]) -> List[ComplianceRule]:
    with open(registry_path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
    try:
        rules = [ComplianceRule(**r) for r in data["rules"]]
    except ValidationError as exc:
        # Surface the offending rule at init, not mid-scan.
        raise SystemExit(f"Invalid compliance_registry.yaml:\n{exc}") from exc
    # Filtering by level is the whole point: an A-only preview run never
    # even loads the AA/AAA rules, so they cannot produce noise.
    return [r for r in rules if r.level in target_levels]

# Pipeline execution context: this environment gates at A + AA.
active_rules = load_rules_by_level("compliance_registry.yaml", ["A", "AA"])

3. Drive the engine with only the mapped tags

The registry and the scanner must agree on which criteria run. Derive the engine’s active tag set from the filtered rules rather than hard-coding it, so the two can never drift. In axe-core, runOnly with type: "tag" restricts evaluation to exactly those conformance tags — the mechanism that stops axe from reporting AAA findings on an AA run.

def axe_run_options(rules: List[ComplianceRule]) -> dict:
    tags = sorted({r.engine_tag for r in rules})
    # axe evaluates ONLY these tags; AAA rules are absent from an AA build,
    # so no AAA violation can appear in the result set to begin with.
    return {"runOnly": {"type": "tag", "values": tags}}

# Passed straight into page.evaluate("(opts) => axe.run(document, opts)", options)
options = axe_run_options(active_rules)

4. Evaluate fully hydrated DOM, not the initial response

Single-page applications mutate the DOM after the initial server response, so a scan fired too early evaluates markup no user ever sees and misreports conformance for the mapped criteria. Gate evaluation on the framework’s readiness signal — the same discipline covered in dynamic content boundary detection — before running the tag-scoped engine, and map boundary events (route transitions, lazy-loaded trees) to the criteria they affect, such as 4.1.2 Name, Role, Value.

async def scan_at_level(page, options: dict, ready_selector: str = "[data-a11y-ready]") -> dict:
    await page.goto(page.url, wait_until="networkidle")
    # Wait for the app's own "hydrated" sentinel, not a fixed sleep, so the
    # mapped criteria are evaluated against the settled UI state.
    await page.wait_for_selector(ready_selector, timeout=10_000)
    axe_src = open("node_modules/axe-core/axe.min.js", encoding="utf-8").read()
    await page.add_script_tag(content=axe_src)
    return await page.evaluate("async (opts) => await axe.run(document, opts)", options)

5. Gate the pipeline on level and severity together

Integrate the mapped result into deployment. A and AA violations at serious/critical block; everything AAA routes to a backlog; the rest logs as tracked debt. The gate reads both axes and never treats the raw violation count as the signal.

import sys

def evaluate_gate(findings: list[dict], rules_by_wcag: dict[str, ComplianceRule]) -> int:
    blocking = []
    for f in findings:
        rule = rules_by_wcag.get(f["wcag_id"])
        if rule is None:
            # Unmapped criterion: fail closed, do not silently pass.
            blocking.append(f)
            continue
        if rule.action == "block" and f["impact"] in ("serious", "critical"):
            blocking.append(f)
    if blocking:
        print(f"BLOCK: {len(blocking)} A/AA violations breach the gate")
        return 1
    return 0

sys.exit(evaluate_gate(scan_findings, {r.wcag_id: r for r in active_rules}))

Wire this into CI as a dedicated stage. Level A and AA breaches fail the run; AAA findings generate asynchronous tracking tickets instead of blocking the merge:

name: Accessibility Compliance Gate
on: [pull_request, push]

jobs:
  wcag-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Execute tiered compliance scan
        run: |
          python audit_runner.py \
            --registry compliance_registry.yaml \
            --levels A,AA \
            --output results.json
      - name: Evaluate gating thresholds
        run: python ci_gate.py --input results.json --block-on serious,critical
      - name: Route AAA findings to backlog
        if: always()
        run: python backlog_router.py --input results.json --level AAA --jira-project A11Y

6. Route AAA to a backlog, not a blocker

Aspirational tiers need different operational handling from baseline legal requirements. Route AAA findings to a dedicated accessibility backlog on a quarterly cadence, weighted by severity and component ownership so the highest-ROI usability fixes surface without stalling sprint velocity. The detailed threshold matrices — which AAA criteria to adopt selectively, and how to score them — live in setting up AAA compliance thresholds for enterprise apps.

Configuration Reference

The registry rule-object fields and the environment inputs that govern them. Keep the table’s vocabulary aligned with axe-core’s tags so the engine and the registry never disagree.

Field / input Type Default Description
id str Stable internal rule identifier; survives WCAG renumbering so legacy suites keep resolving.
wcag_id str The success criterion number (e.g. 1.4.3). The join key between findings and registry.
level enum A | AA | AAA. The scope axis; drives which rules a given environment loads.
engine_tag str axe-core tag (wcag2a, wcag2aa, wcag21aa, wcag22aa, wcag2aaa) that scopes the actual scan.
severity enum serious minor|moderate|serious|critical. The impact axis; combined with action at the gate.
action enum block block (fail CI) | log (tracked debt) | backlog (AAA quarterly queue).
TARGET_LEVELS list ["A","AA"] Env input: which levels this environment loads and gates on.
BLOCK_ON list ["serious","critical"] Env input: minimum impact at which a block rule fails the pipeline.

Verification and Testing

  • Level filtering is exclusive. Assert load_rules_by_level(path, ["A", "AA"]) returns zero rules whose level == "AAA". If an AAA rule leaks into an AA run, the engine will report AAA findings and the gate will start blocking on them.
  • Registry validates at init. Feed a rule with level: "AAAA" or a missing action and assert the loader raises SystemExit. A silent skip here is how a mandatory criterion quietly stops being enforced.
  • Engine tags match the filtered set. Assert axe_run_options(active_rules)["runOnly"]["values"] contains no wcag2aaa when the target is A+AA. This is the guard against the engine and registry drifting apart.
  • Gate reads both axes. Construct a finding that is Level AAA but critical impact and assert evaluate_gate does not block; construct a Level AA serious finding and assert it does.
  • Unmapped criteria fail closed. Pass a finding whose wcag_id is absent from the registry and assert the gate blocks rather than passes — new WCAG 2.2 criteria must be mapped before they can be waved through.

Failure Modes and Troubleshooting

AAA criteria blocking deployments. The pipeline fails on enhanced-contrast or focus-appearance findings that require redesign. Root cause is almost always an AAA rule whose action defaulted to block, or an environment loading AAA when its target is AA. Fix: set action: "backlog" on every AAA rule and confirm TARGET_LEVELS excludes AAA for that environment; verify with the level-filtering test above.

Criteria silently missing from the run. A mapped criterion never produces findings even on known-failing pages. Root cause is an engine_tag in the registry that axe-core does not recognize (wcag22a instead of wcag2a, or a typo). Because runOnly only evaluates tags it knows, the criterion is simply skipped. Fix: constrain engine_tag to axe’s published tag list and assert the derived tag set is non-empty for every level you gate on.

False-positive fatigue collapsing the gate. Developers begin blanket-suppressing because every scan floods the PR. Root cause is running all three levels at block and conflating severity with level. Fix: restore the two-axis model — load only the target levels, block only on serious/critical, and demote the rest to tracked debt so the blocking set stays small and credible.

Level and severity conflated into one score. A single “compliance score” hides whether a regression is a legal-scope AA barrier or a cosmetic AAA gap. Fix: keep the axes separate in both the registry and the report; never sum them. The gate decision is action == "block" AND impact >= threshold, evaluated on the two fields independently.

New WCAG 2.2 criteria unmapped and unenforced. Criteria added in 2.2 (2.4.11 Focus Not Obscured, 2.5.8 Target Size) exist in axe but have no registry entry, so the gate has no action for them. Fix: fail closed on unmapped wcag_ids (as in step 5) and reconcile the registry against axe’s rule metadata each engine upgrade so newly shipped criteria surface as a gate warning, not a blind spot.

Frequently Asked Questions

How do I stop axe-core from reporting AAA violations on an AA run?

Restrict the engine with runOnly: { type: "tag", values: [...] } and include only the AA-and-below tags (wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa). Derive that list from your filtered registry rules rather than hard-coding it, so the engine can never evaluate a tag the registry did not authorize. If AAA findings still appear, a rule with an AAA engine_tag slipped past your level filter — assert the filtered set contains no wcag2aaa.

Which axe-core tag corresponds to each conformance level?

axe namespaces tags by both WCAG version and level: wcag2a/wcag2aa/wcag2aaa for 2.0, wcag21a/wcag21aa for 2.1 additions, and wcag22aa for 2.2 additions. There is no single “AA” tag — a full AA run is the union of wcag2aa, wcag21aa, and wcag22aa. Map each registry rule to the specific version+level tag that introduced its criterion, and take the union at scan time. The best-practice tag is not a WCAG level and should map to action: "log", never block.

Should the CI gate fail on every mapped violation?

No. Gate on the two axes together: block only when action == "block" and impact is serious or critical, warn on minor/moderate as tracked debt, and route the entire AAA set to a backlog. A binary “any violation fails” gate is what drives suppression and hollows out the whole mapping; keeping the blocking set small and legally meaningful is what makes teams trust it.

Where do suppressions and scoped-out rules belong?

Structural exclusions you never own — third-party embeds, analytics widgets, chart libraries tripping contrast — belong in the engine’s exclude scoping under your axe-core enterprise configuration, so they never generate a finding. Review-gated suppressions belong in the triage layer, auditable and reversible. Never hard-code a level override inline in scan code, where it escapes review and drifts out of sync with the registry.

How do compliance-level results feed the rest of the pipeline?

The mapped, gated findings become the input contract for downstream stages: they flow into error categorization and triage pipelines for routing and dedupe, and the level/severity fields drive the trend telemetry your dashboards read. Retention of that telemetry — and keeping it clear of sensitive user data — is governed by your security and privacy framework integration and audit data storage and retention policies, so the level mapping stays a source of decisions rather than a source of liability.