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
CLPclaim level and eachSVCservice line. Calldispatchonce 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/MIAwith 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, neverdate.today(). - Treating the default as a denial by accident. The
defaultrule setsis_denial: trueso 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.
Related
- Parent guide: CARC/RARC Denial Routing — the CAS segment structure and the code vocabulary this engine consumes.
- Classifying Denials by CARC Group Code — the group-code rules that set the
is_denialflag each rule carries. - Versioning Payer Rules with Effective Dates — the effective-date pattern applied to payer edit tables.