Building a CARC / RARC Routing Engine

Problem: your denial-management automation reads CARC and RARC codes off the X12 835 CAS segment, but the routing rules are buried in a growing chain of if group == "CO" and carc == "197" branches — every new payer behavior means a code change and a redeploy, and nobody can audit why a claim landed in a given queue. This guide builds a config-driven routing engine that maps each (CAS01 group, CARC, RARC) combination to a work queue, an owner role, and a priority, driven by a versioned table you can edit without touching code. It is the productionization of the router sketched in CARC/RARC Denial Routing, and it targets revenue cycle engineers and Python automation developers who own denial throughput.

Prerequisites

Spec Reference: The Routing Key and Its Fallback Order

The engine resolves each adjustment against the most specific rule that matches, then falls back to progressively broader keys. Encode the resolution order explicitly; never let an unmatched code silently vanish.

Match level Key Example Resolves to
1 — most specific (group, CARC, RARC) (CO, 96, N130) Non-covered per benefit — patient-responsibility review
2 (group, CARC) (CO, 197) Authorization appeal, appeals team, priority 1
3 (group,) (PR,) Patient statement queue
4 — final fallback default any unmatched Manual triage queue, flagged for code-list refresh

Each rule carries a queue, an owner, a priority (1 = highest), and an is_denial flag. The table also carries an effective_date; the engine loads the newest rule set whose effective_date is on or before the remittance production date (BPR16 on the 835), so a stored denial always resolves against the code list that was in force when the payer issued it.

Step-by-Step Implementation

Step 1 — Define the versioned routing table

Keep rules in a file so billing analysts can edit them without a deploy. Each entry’s key is the group|carc|rarc join, with empty segments for the broader levels.

# routing_table_2026-07.yaml
effective_date: "2026-07-01"
version: "2026.2"
rules:
  "CO|197|":   {queue: auth_appeal,               owner: appeals,         priority: 1, is_denial: true}
  "CO|50|":    {queue: medical_necessity_appeal,  owner: clinical,        priority: 1, is_denial: true}
  "CO|97|":    {queue: coding_rework,             owner: coder,           priority: 2, is_denial: true}
  "CO|16|":    {queue: missing_info_rework,       owner: biller,          priority: 2, is_denial: true}
  "CO|45|":    {queue: contractual_writeoff,      owner: auto_post,       priority: 5, is_denial: false}
  "CO|96|N130": {queue: patient_benefit_review,   owner: patient_billing, priority: 3, is_denial: false}
  "OA|18|":    {queue: duplicate_review,          owner: biller,          priority: 3, is_denial: false}
  "PR||":      {queue: patient_statement,         owner: patient_billing, priority: 4, is_denial: false}
  "CR||":      {queue: reversal_repost,           owner: cash_posting,    priority: 2, is_denial: false}
default:       {queue: manual_triage,             owner: biller,          priority: 3, is_denial: true}

Step 2 — Load the table and select by effective date

Load every table version at startup, sort by effective_date, and pick the newest one that is not after the remittance date. This keeps historical remittances resolving against historical rules.

from __future__ import annotations

import glob
from dataclasses import dataclass
from datetime import date

import yaml


@dataclass(frozen=True)
class Rule:
    queue: str
    owner: str
    priority: int
    is_denial: bool


@dataclass(frozen=True)
class RoutingTable:
    version: str
    effective_date: date
    rules: dict[str, Rule]
    default: Rule


def load_tables(pattern: str) -> list[RoutingTable]:
    tables: list[RoutingTable] = []
    for path in glob.glob(pattern):
        with open(path, encoding="utf-8") as fh:
            raw = yaml.safe_load(fh)
        tables.append(
            RoutingTable(
                version=raw["version"],
                effective_date=date.fromisoformat(raw["effective_date"]),
                rules={k: Rule(**v) for k, v in raw["rules"].items()},
                default=Rule(**raw["default"]),
            )
        )
    # Newest first so selection is a simple linear scan.
    return sorted(tables, key=lambda t: t.effective_date, reverse=True)


def select_table(tables: list[RoutingTable], remit_date: date) -> RoutingTable:
    for table in tables:  # already sorted newest-first
        if table.effective_date <= remit_date:
            return table
    raise ValueError(f"no routing table effective on or before {remit_date}")

Step 3 — Resolve an adjustment with graded fallback

The resolver tries the three-part key, then two-part, then group-only, then the default. Building the candidate keys in order makes the precedence explicit and testable.

import logging

logger = logging.getLogger("routing_engine")


def resolve(table: RoutingTable, group: str, carc: str,
            rarc: str | None, clp01: str) -> Rule:
    """Most-specific-first resolution: (g,c,r) -> (g,c) -> (g) -> default."""
    candidates = [
        f"{group}|{carc}|{rarc or ''}",
        f"{group}|{carc}|",
        f"{group}||",
    ]
    for key in candidates:
        rule = table.rules.get(key)
        if rule is not None:
            _log(clp01, group, carc, rarc, rule, table.version, matched=key)
            return rule
    _log(clp01, group, carc, rarc, table.default, table.version, matched="default")
    return table.default


def _log(clp01: str, group: str, carc: str, rarc: str | None,
         rule: Rule, version: str, matched: str) -> None:
    # HIPAA-safe: log only the CLP01 control number and code values.
    logger.info(
        "routed", extra={
            "clp01": clp01, "group": group, "carc": carc,
            "rarc": rarc or "", "queue": rule.queue,
            "priority": rule.priority, "table": version, "matched_key": matched,
        },
    )

Step 4 — Dispatch to the work queue

Wrap resolution and dispatch behind one call. The dispatcher is where you publish to a broker (SQS, RabbitMQ, a database work table); keep it thin so the routing logic stays pure and unit-testable.

@dataclass(frozen=True)
class WorkItem:
    clp01: str
    queue: str
    owner: str
    priority: int
    is_denial: bool
    table_version: str


def dispatch(table: RoutingTable, group: str, carc: str,
             rarc: str | None, clp01: str) -> WorkItem:
    rule = resolve(table, group, carc, rarc, clp01)
    item = WorkItem(
        clp01=clp01, queue=rule.queue, owner=rule.owner,
        priority=rule.priority, is_denial=rule.is_denial,
        table_version=table.version,
    )
    # ... publish `item` to the message broker / work table here ...
    return item

Verification

Confirm both the specific-match and fallback paths before wiring the engine to a live broker. A CARC 197 must hit the auth_appeal rule; an unmapped CARC must land in manual_triage, not disappear.

tables = load_tables("routing_table_*.yaml")
from datetime import date
table = select_table(tables, remit_date=date(2026, 7, 15))

hit = dispatch(table, "CO", "197", None, clp01="PCN-000481")
assert hit.queue == "auth_appeal" and hit.priority == 1 and hit.is_denial

miss = dispatch(table, "CO", "999", None, clp01="PCN-000482")
assert miss.queue == "manual_triage"  # fell through to default, not dropped

Expected log output (control number and codes only — no PHI):

{"level":"INFO","msg":"routed","clp01":"PCN-000481","group":"CO","carc":"197","rarc":"","queue":"auth_appeal","priority":1,"table":"2026.2","matched_key":"CO|197|"}
{"level":"INFO","msg":"routed","clp01":"PCN-000482","group":"CO","carc":"999","rarc":"","queue":"manual_triage","priority":3,"table":"2026.2","matched_key":"default"}

The matched_key field is the audit anchor: it records exactly which rule fired and which table version produced the decision, so a downstream reviewer can reconstruct any routing outcome.

Common Gotchas

  • One CAS segment, many triplets. A CAS segment carries up to six adjustment triplets, and a claim carries CAS at both the CLP claim level and each SVC service line. Call dispatch once per triplet, not once per segment — collapsing to the first reason code silently drops adjustments and unbalances the claim.
  • RARC-only remarks. A remark code in MOA/MIA with no accompanying CARC is informational, not a routing key on its own. Only escalate to the three-part (group, CARC, RARC) key when a CARC is present; otherwise resolve on group plus CARC and record the RARC as context.
  • Effective-date drift. Resolving a six-month-old remittance against today’s table mis-routes any code that was deactivated or re-scoped in the interim. Always pass the remittance production date to select_table, never date.today().
  • Treating the default as a denial by accident. The default rule sets is_denial: true so unmatched codes surface for review, but that makes them count toward denial-rate metrics until a human classifies them. Alert on default-match volume so a code-list gap does not quietly inflate your denial rate.

Up: CARC/RARC Denial Routing