Prior Authorization Routing Automation

A claim that comes back with NCPDP reject 75 (Prior Authorization Required) has not failed adjudication — it has been handed off. Prior authorization routing is the subsystem that catches that reject, opens a governed PA case for the exact member and drug that triggered it, and then drives that case through a bounded state machine — pended, approved, denied, overridden, appealed, or expired — until a terminal outcome flows back into the adjudication engine and the original claim can be re-priced or finally rejected. This area specifies that handoff as a deterministic, idempotent, auditable subsystem rather than a queue of manual work, extending the rule surface defined in Formulary Validation & Rule Engine Design. The failure that automation exists to prevent is the silent one: a member standing at a pharmacy counter whose PA was approved in one system but never synced back to the engine that rejected them.

Problem Framing

Within the rule engine, prior authorization is the branch that fires when a formulary edit determines a drug is coverable conditionally rather than outright. The upstream decision — whether this 407-D7 Product/Service ID (NDC), resolved through the NDC-to-GPI Crosswalk Automation to a therapeutic class, actually requires authorization for this member and plan — belongs to Step Therapy & Prior Auth Trigger Rules. Routing begins the moment that determination returns “PA required” and the adjudicator emits reject 75. From there, the claim is no longer a point-of-sale pricing problem; it is a case with a lifecycle that can span seconds (an auto-override) or weeks (a clinical appeal). The engineering problem is to model that lifecycle as a state machine whose transitions are deterministic, idempotent, and replayable, so that a PA outcome is never lost, never double-created, and never stale when it reaches the claim. Everything in this area treats the PA case as an event-sourced entity keyed on (member_token, GPI) and version-stamped against the formulary snapshot that raised the reject.

Prerequisites

PA routing is a downstream consumer of the adjudication path, not an entry point. Before a case is opened, the following must already hold:

  • A PA-required determination with its reason. Routing does not decide whether a drug needs authorization; it consumes the boolean plus the triggering rule id from the trigger-rule evaluation. A reject 75 with no attached rule reference is a defect, not a case — it means the trigger layer emitted the code without recording why, and the case cannot be adjudicated or audited later.
  • Normalized member and drug context. The case is keyed on the tokenized member identifier and the GPI, never on the raw 407-D7 NDC. Package-level NDC variance must already be collapsed to a therapeutic class upstream, or two fills of the same drug open two unrelated PA cases.
  • PHI already tokenized. By the time a case is opened, 302-C2 Cardholder ID and 310-CA Patient First Name have been stripped and replaced with an opaque member_token, exactly as required by Security & Compliance Boundaries for Claims Data. The PA engine reasons over tokens and taxonomy identifiers only; the clinical narrative that a reviewer sees lives behind a separate PHI-scoped service, never in the routing engine’s event log.
  • A version-stamped formulary snapshot. The PA policy that governs a case — which overrides auto-approve, how long an approval is valid — is read from the dated snapshot active at the dispensing timestamp, so a case opened in June is adjudicated and later replayed against June’s policy even if July’s policy differs.
  • An append-only event store. Every transition is an immutable event. The current state of a case is a fold over its event history, not a mutable row that later transitions overwrite. This is what makes the whole subsystem replayable during a payer examination.
  • Library baseline. Python 3.11+, pydantic>=2.6 for strict transition contracts, structlog or stdlib logging for JSON telemetry, and decimal.Decimal for the copay and cost-share figures an approval carries. See the Pydantic documentation and the decimal module for the exact APIs used below.

PA State Specification

A PA case is a finite state machine with six states and a fixed transition table. Modeling it as an explicit table — rather than as scattered if branches across the codebase — is what keeps the transitions total, auditable, and safe to replay. Every transition is triggered by a named event, guarded by a precondition, and produces exactly one appended event.

State Meaning Entered by Legal next states Terminal?
PENDING Case opened, awaiting a decision reject 75 from trigger rules APPROVED, DENIED, OVERRIDDEN, EXPIRED no
APPROVED Clinical approval on file reviewer decision or upheld appeal EXPIRED no (time-bounded)
OVERRIDDEN Auto-approved by a policy-gated override valid 420-DK / 461-EU override EXPIRED no (time-bounded)
DENIED Authorization refused reviewer decision APPEALED no
APPEALED Denial under formal appeal member/prescriber appeal filed APPROVED, DENIED no
EXPIRED Approval window elapsed authorization end-date passed PENDING (resubmission) effectively terminal

Two invariants wrap the table. First, every transition is idempotent under an idempotency key: replaying the same (case_id, event_id) must leave the case in the identical state and append no second event. Second, no transition is legal that the table does not name: an event that would drive APPROVED → DENIED directly, without an intervening EXPIRED and a fresh PENDING, is rejected as an illegal transition and logged as a conflict rather than silently applied. The transitions that carry money — an approval that authorizes a specific quantity at a specific cost share — recompute copay through Tier Mapping & Copay Calculation Logic using Decimal, never float.

The reject codes the routing layer reads and writes are load-bearing:

Code Field Meaning in routing
75 511-FB Reject Code Prior authorization required — opens or re-asserts a case
569 511-FB Reject Code Provide beneficiary with notice — attached on a denial
76 511-FB Reject Code Plan limitations exceeded — a distinct edit, not a PA path

Reference Python Implementation

The routing engine below models the case as an event-sourced entity. It exposes a single apply method that takes the current case and a transition event, validates the transition against the table, and returns the next case plus the event to append — never mutating in place. Idempotency is enforced by carrying the last applied event_id; a replayed event is a no-op. The engine accepts only tokenized identifiers and logs only tokens, states, and reject codes.

python
import json
import logging
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, field_validator, ConfigDict

# Structured JSON logging for SIEM ingestion. PHI GUARDRAIL: only the opaque
# member_token, GPI, case_id and outcome ever reach this logger -- never
# 302-C2 Cardholder ID, 310-CA Patient Name, or raw claim bytes.
logging.basicConfig(format="%(message)s", level=logging.INFO)
logger = logging.getLogger("pa_routing")

REJ_PA_REQUIRED = "75"    # 511-FB Reject Code: Prior Authorization Required
REJ_NOTICE = "569"        # 511-FB Reject Code: Provide beneficiary with notice
CENTS = Decimal("0.01")


class PAState(str, Enum):
    PENDING = "PENDING"
    APPROVED = "APPROVED"
    OVERRIDDEN = "OVERRIDDEN"
    DENIED = "DENIED"
    APPEALED = "APPEALED"
    EXPIRED = "EXPIRED"


# The transition table IS the policy. Any event not named here is illegal.
LEGAL: dict[PAState, set[PAState]] = {
    PAState.PENDING: {PAState.APPROVED, PAState.DENIED, PAState.OVERRIDDEN, PAState.EXPIRED},
    PAState.APPROVED: {PAState.EXPIRED},
    PAState.OVERRIDDEN: {PAState.EXPIRED},
    PAState.DENIED: {PAState.APPEALED},
    PAState.APPEALED: {PAState.APPROVED, PAState.DENIED},
    PAState.EXPIRED: {PAState.PENDING},
}


class PACase(BaseModel):
    """Current folded state of one PA case. Non-PHI by construction."""
    model_config = ConfigDict(extra="forbid")

    case_id: str                 # deterministic hash of (member_token, GPI, request_window)
    member_token: str            # opaque; 302-C2 already stripped upstream
    gpi: str                     # 14-digit therapeutic class, not raw 407-D7 NDC
    state: PAState
    last_event_id: Optional[str] = None
    authorized_copay: Optional[Decimal] = None   # Decimal money, never float
    auth_expires_at: Optional[str] = None
    snapshot_version: str


class TransitionEvent(BaseModel):
    model_config = ConfigDict(extra="forbid")

    event_id: str                # idempotency key; unique per intended transition
    case_id: str
    to_state: PAState
    occurred_at: str
    override_461eu: Optional[str] = None   # 461-EU Prior Authorization Type Code
    override_number_462ev: Optional[str] = None  # 462-EV PA Number Submitted
    authorized_copay: Optional[Decimal] = None

    @field_validator("authorized_copay")
    @classmethod
    def _quantize(cls, v: Optional[Decimal]) -> Optional[Decimal]:
        return None if v is None else v.quantize(CENTS, rounding=ROUND_HALF_UP)


class TransitionRejected(Exception):
    """Raised when an event would drive an illegal or conflicting transition."""


def apply(case: PACase, event: TransitionEvent) -> PACase:
    """Deterministic, idempotent transition. Returns the next case; never mutates."""
    if event.case_id != case.case_id:
        raise TransitionRejected("event/case mismatch")

    # Idempotency: a replayed event is a no-op that returns the identical state.
    if event.event_id == case.last_event_id:
        _audit(case, event, outcome="idempotent_replay")
        return case

    if event.to_state not in LEGAL[case.state]:
        _audit(case, event, outcome="illegal_transition")
        raise TransitionRejected(f"{case.state} -> {event.to_state} not permitted")

    next_case = case.model_copy(update={
        "state": event.to_state,
        "last_event_id": event.event_id,
        "authorized_copay": event.authorized_copay or case.authorized_copay,
    })
    _audit(next_case, event, outcome="applied")
    return next_case


def _audit(case: PACase, event: TransitionEvent, outcome: str) -> None:
    # Append-only audit event: tokens + taxonomy + outcome only, no PHI.
    logger.info(json.dumps({
        "event": "pa_transition",
        "case_id": case.case_id,
        "member_token": case.member_token,     # opaque, non-PHI
        "gpi": case.gpi,
        "from_state": case.state.value,
        "to_state": event.to_state.value,
        "event_id": event.event_id,
        "outcome": outcome,
        "reject_code": REJ_PA_REQUIRED if case.state == PAState.PENDING else None,
        "snapshot_version": case.snapshot_version,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }))

The engine is a pure function of (case, event): given the same case and event it returns the same next case and appends the same audit line, which is what makes failover and re-adjudication safe. The case_id is a deterministic hash of (member_token, GPI, request_window) so that two concurrent claims for the same member and drug in the same window resolve to the same case rather than two — the concurrency guarantee that Resolving PA Pend Race Conditions hardens. Money on an approval crosses the wire as a quantized Decimal string; a float copay would silently break cost-share rounding downstream.

The full lifecycle the engine enforces is shown below. Each edge is a named, guarded transition; the terminal band on the right is where a resolved case flows back into adjudication.

Prior authorization case lifecycle state machine A claim rejected with NCPDP code 75 opens a PENDING prior-authorization case. From PENDING the case can transition to APPROVED on a clinical approval, to OVERRIDDEN on a valid policy-gated 420-DK override, to DENIED on a clinical refusal, or to EXPIRED when the PA window elapses. An OVERRIDDEN case joins the APPROVED path and both resume adjudication so the claim can be re-priced. A DENIED case can be APPEALED; an overturned appeal returns to APPROVED. An EXPIRED case resumes adjudication with reject 75 still in force and may be resubmitted as a new PENDING case. Reject 75 · PA required PENDING case opened OVERRIDDEN 420-DK auto-approve APPROVED clinical approval DENIED reject 569 notice EXPIRED window elapsed APPEALED under review Resume adjudication re-price or final reject approve auto-override deny override applied PA window elapsed appeal filed · 569 overturned resume reject 75 persists

Figure: The PA case lifecycle — reject 75 opens a PENDING case that resolves to APPROVED, OVERRIDDEN, DENIED, or EXPIRED; denials route to APPEALED, and every terminal path resumes adjudication.

Engineering Constraints & Known Failure Modes

PA routing fails in specific, recurring ways. Each has a deterministic rule rather than an ad-hoc except:

  • Pend races. Two point-of-sale claims for the same member and drug arrive milliseconds apart; both see “no open case” and both create a PENDING case, producing duplicate reviews and later conflicting decisions. The case_id is derived deterministically from (member_token, GPI, request_window) and enforced by an atomic upsert so the second writer joins the existing case instead of creating a rival. The full treatment — locking strategy, idempotency-key dedup, and the DB unique constraint — is in Resolving PA Pend Race Conditions.
  • Duplicate PA requests across channels. A prescriber portal submission and a pharmacy reject can both open a case for the same (member, GPI). Deduplication keys on the deterministic case_id, not on the submission channel, so cross-channel duplicates converge.
  • Stale approvals. An APPROVED case has an auth_expires_at. A claim that arrives after that date must not silently re-use the approval; the case transitions to EXPIRED and the incoming claim re-asserts reject 75. Expiry is evaluated against the dispensing timestamp, never wall-clock at read time, so a replayed historical claim sees the approval that was valid then.
  • Override conflicts. An automated 420-DK override and a concurrent clinical denial can race on a PENDING case. The transition table plus the idempotency key make the outcome deterministic — whichever event the store commits first wins, and the loser hits an illegal-transition guard rather than overwriting. Which overrides may auto-apply at all, versus which must route to a human, is decided in Automating PA Override Approvals.
  • Split-brain status across systems. The PA system, the adjudication engine, and member/prescriber portals can hold divergent views of the same case. Reconciliation on case_id with version vectors, and idempotent status ingestion, are covered in Syncing PA Appeal Status Across Systems.
  • Backend outage on the PA path. If the PA store is unreachable, the router must not default to APPROVE. It emits reject 75 (case unresolved) and degrades through Fallback Routing Logic Design rather than authorizing an unreviewed fill.
  • PHI leakage in error paths. The tempting except that logs the offending claim is the classic HIPAA exposure. Every audit line carries member_token, gpi, case_id, and the outcome — never 302-C2 Cardholder ID, 310-CA Patient Name, or the transaction body. Validation errors log the failure shape, not the payload, consistent with NCPDP Reject Code Reference.

Performance & Correctness Tuning

  • Idempotency keys everywhere. Every transition carries an event_id unique to the intended change. Retries, at-least-once queue redeliveries, and failover re-applies all collapse to a single applied event, so exactly-once semantics hold without distributed locks on the hot path.
  • Event ordering. Transitions are applied in the order the event store commits them, not the order they arrive over the wire. Each event carries occurred_at plus a monotonic sequence per case so an out-of-order delivery is reordered or rejected, never applied blindly — the same discipline the sync page formalizes with version vectors.
  • Snapshotting the fold. A long-lived case (one that expires and resubmits repeatedly) accumulates events. Periodically materialize a snapshot of the folded PACase and truncate replay to “latest snapshot + subsequent events” so state reconstruction stays O(recent events), not O(all history), while the full log remains for audit.
  • Version-stamped policy. Every case records the snapshot_version of the formulary/PA policy that governs it, so an approval’s validity window and the set of auto-approvable overrides are those that were live when the case opened — the versioned-snapshot replay guarantee this whole engine depends on.
  • Decimal money, always. Any copay or cost-share an approval authorizes uses decimal.Decimal, quantized to cents with ROUND_HALF_UP, and crosses the wire as a string. A float here silently corrupts member cost-share.
  • Audit before acknowledge. Serialize the transition event to the append-only store before acknowledging the triggering message, so a crash between decision and acknowledgement replays the decision rather than losing it.

In This Section

  • Resolving PA Pend Race Conditions — preventing duplicate case creation and lost updates when concurrent claims pend the same member and GPI, using idempotency keys, atomic upserts, and a database unique constraint.
  • Automating PA Override Approvals — which 420-DK, 461-EU, and 462-EV override submissions can be auto-approved safely versus which must route to clinical review, with the policy gate and copay recompute.
  • Syncing PA Appeal Status Across Systems — keeping the adjudication engine, the PA system, and portals eventually consistent through idempotent, out-of-order-tolerant status ingestion.

← Back to Formulary Validation & Rule Engine Design