Classifying Denials by CARC Group Code

Problem: your denial dashboard says the denial rate jumped, but half of what it counts is not a denial at all — patient deductibles under group PR and contractual write-downs under CO CARC 45 are being lumped in with genuine authorization and medical-necessity rejections. Before you can route, appeal, or measure anything, every X12 835 CAS adjustment must be sorted into an actionable bucket: write-off, appealable, patient-bill, or rework. This guide builds a deterministic classifier keyed on the CAS01 group code and the CARC that keeps those buckets clean. It underpins the router in CARC/RARC Denial Routing and targets revenue cycle engineers and Python billing developers.

Prerequisites

Spec Reference: Group Code to Actionable Bucket

The group code is the first-order signal for the bucket; the CARC refines the edge cases. The mapping below is the classifier’s backbone — note that PR is never a denial and that CO splits between a pure write-off (CARC 45) and appealable denials.

CAS01 group Meaning Default bucket Key exception
CO Contractual Obligation Appealable / rework CO 45 (fee-schedule) and CO 253 (sequestration) are write-offs, not appealable
PR Patient Responsibility Patient-bill Never a denial — deductible, copay, coinsurance
OA Other Adjustment Rework OA 23 (prior payer adjudication) is informational for COB
PI Payer-Initiated Reduction Appealable Payer chose to reduce without a contract basis — often appealable
CR Correction and Reversal Write-off / repost Paired with a matching positive/negative reversal; nets to zero

The four actionable buckets the classifier emits are: WRITE_OFF (post the adjustment, no pursuit), APPEALABLE (recoverable with documentation), PATIENT_BILL (move to the patient statement), and REWORK (fix and resubmit the claim).

Step-by-Step Implementation

Step 1 — Define the bucket enum and group codes

Model the outcome as a closed enum so downstream code can match on it exhaustively, and model group codes as a StrEnum so they compare directly against the parsed CAS01 string.

from __future__ import annotations

import enum
from dataclasses import dataclass
from decimal import Decimal


class GroupCode(enum.StrEnum):
    CO = "CO"  # Contractual Obligation
    PR = "PR"  # Patient Responsibility
    OA = "OA"  # Other Adjustment
    PI = "PI"  # Payer Initiated Reduction
    CR = "CR"  # Correction and Reversal


class Bucket(enum.StrEnum):
    WRITE_OFF = "WRITE_OFF"        # post adjustment, do not pursue
    APPEALABLE = "APPEALABLE"      # recoverable with documentation
    PATIENT_BILL = "PATIENT_BILL"  # move to patient statement
    REWORK = "REWORK"             # correct and resubmit


@dataclass(frozen=True)
class Adjustment:
    group: GroupCode
    carc: str
    amount: Decimal

Step 2 — Encode the CARC exceptions

Some CARCs override their group’s default bucket. Keep these as explicit sets so the intent is auditable and a new payer behavior is a one-line data change, not a control-flow edit.

# CO CARCs that are pure contractual write-offs, never appealable.
CO_WRITE_OFF_CARCS: frozenset[str] = frozenset({
    "45",   # charge exceeds fee schedule / maximum allowable
    "253",  # sequestration — federal payment reduction
    "59",   # multiple/concurrent procedure reduction
})

# CO CARCs that mean "fix the claim and resubmit" rather than appeal.
CO_REWORK_CARCS: frozenset[str] = frozenset({
    "16",   # lacks information / billing error (usually with a RARC)
    "97",   # bundled — review NCCI, consider a modifier
})

Step 3 — Write the classification rules

Classify on the group code first, then apply CARC exceptions. The match statement makes the group-level defaults explicit; the CO branch is the only one that consults the CARC sets.

def classify(adj: Adjustment) -> Bucket:
    """Sort one CAS adjustment into an actionable bucket."""
    match adj.group:
        case GroupCode.PR:
            # Patient responsibility is a balance, never a denial.
            return Bucket.PATIENT_BILL
        case GroupCode.CR:
            # Correction/reversal nets against a prior posting.
            return Bucket.WRITE_OFF
        case GroupCode.CO:
            if adj.carc in CO_WRITE_OFF_CARCS:
                return Bucket.WRITE_OFF
            if adj.carc in CO_REWORK_CARCS:
                return Bucket.REWORK
            return Bucket.APPEALABLE
        case GroupCode.PI:
            # Payer chose to reduce without a contract basis — pursue it.
            return Bucket.APPEALABLE
        case GroupCode.OA:
            return Bucket.REWORK
    # Unreachable for valid group codes; guard against malformed 835.
    raise ValueError(f"unhandled group code: {adj.group!r}")


def is_denial(bucket: Bucket) -> bool:
    """Only appealable and reworkable adjustments count as denials."""
    return bucket in (Bucket.APPEALABLE, Bucket.REWORK)

Step 4 — Classify a claim and keep the denial count honest

Classify every triplet on the claim, then derive the denial flag. A claim can carry a write-off and an appealable denial in the same CAS segment, so classify per adjustment, never per claim.

def classify_claim(adjustments: list[Adjustment]) -> list[tuple[Adjustment, Bucket, bool]]:
    results = []
    for adj in adjustments:
        bucket = classify(adj)
        results.append((adj, bucket, is_denial(bucket)))
    return results


sample = [
    Adjustment(GroupCode.CO, "45", Decimal("31.40")),   # write-off
    Adjustment(GroupCode.CO, "197", Decimal("120.00")),  # appealable
    Adjustment(GroupCode.PR, "1", Decimal("25.00")),     # patient bill
]
for adj, bucket, denial in classify_claim(sample):
    print(adj.group, adj.carc, "->", bucket, "denial" if denial else "not a denial")

Verification

Confirm the three edge cases that distort denial metrics most: a PR deductible is not a denial, a CO 45 write-down is not a denial, and a CO 197 authorization rejection is. Then assert the claim balances so no adjustment was lost in classification.

assert classify(Adjustment(GroupCode.PR, "1", Decimal("25.00"))) == Bucket.PATIENT_BILL
assert not is_denial(Bucket.PATIENT_BILL)

assert classify(Adjustment(GroupCode.CO, "45", Decimal("31.40"))) == Bucket.WRITE_OFF
assert not is_denial(Bucket.WRITE_OFF)

assert classify(Adjustment(GroupCode.CO, "197", Decimal("120.00"))) == Bucket.APPEALABLE
assert is_denial(Bucket.APPEALABLE)

# Balancing guard: CLP03 charge == CLP04 paid + patient resp + sum(CAS amounts).
charge, paid, patient = Decimal("176.40"), Decimal("0.00"), Decimal("25.00")
total_adj = sum(a.amount for a in sample)  # 31.40 + 120.00 + 25.00
assert charge == paid + total_adj  # patient PR is itself a CAS PR triplet here

Expected classification output:

CO 45 -> WRITE_OFF not a denial
CO 197 -> APPEALABLE denial
PR 1 -> PATIENT_BILL not a denial

Only one of the three adjustments counts toward the denial rate — exactly the discrimination a raw “count of CAS segments” metric fails to make.

Common Gotchas

  • PR is not a denial. Deductible (CARC 1), coinsurance (CARC 2), and copay (CARC 3) under group PR are the patient’s balance. Counting them as denials inflates the rate and sends staff chasing money the payer never owed.
  • CO 45 is contractual, not a denial. Fee-schedule write-downs are the amount you agreed to forgo in the payer contract. They belong in WRITE_OFF, never in an appeal queue — appealing a contracted adjustment is unwinnable and wastes staff time.
  • Balancing amounts must reconcile. For every claim, CLP03 must equal CLP04 plus patient responsibility plus the signed sum of all CAS amounts. If your classified buckets do not account for every triplet, the claim will not balance — treat a mismatch as a parsing bug, not a routing decision.
  • CR reversals come in pairs. A correction/reversal group adjustment usually offsets a prior posting with an equal, opposite amount. Classify it as a repost, not a denial, and match it to its original so the ledger nets correctly.

Up: CARC/RARC Denial Routing