Step Therapy & Prior Auth Trigger Rules

Step therapy (ST) and prior authorization (PA) trigger rules are the clinical utilization-management gates that decide, deterministically, whether a claim may proceed to cost-sharing or must be rejected or pended for review. They sit inside the Formulary Validation & Rule Engine Design layer, immediately after the drug is confirmed active on formulary and immediately before Tier Mapping & Copay Calculation Logic runs. Get the ordering wrong and the engine quotes a member cost-share for a fill that a clinical policy never authorized. This page specifies the trigger rule set, the NCPDP reject codes each gate emits, a production-grade Python implementation, and the failure modes that make ST/PA the most override-sensitive stage in Pharmacy Benefit Manager (PBM) adjudication.

Unlike a formulary lookup, an ST/PA gate is stateful: it reasons over prior utilization within a clinical lookback window and over plan-level policy attached to a therapeutic class, not just the single claim in front of it. That statefulness is exactly what makes it easy to get subtly wrong under concurrency, and it is what the rest of this page is organized around — precedence, idempotency, and a read-only clinical context that never mutates the downstream financial calculation.

Prerequisites

ST/PA evaluation runs late in the pipeline. Before any of the code below executes, several upstream contracts must already hold:

  • A normalized, PHI-tokenized claim object. The engine never touches raw NCPDP bytes. The ingestion tier has already run NCPDP D.0 message parsing and stripped or tokenized PHI fields — 302-C2 Cardholder ID and 310-CA Patient First Name — at the edge, replacing them with an opaque transaction_id. The gate reads only the fields it needs: 407-D7 Product/Service ID (NDC), 411-DB Prescriber ID (NPI), 401-D1 Date of Service, plus the submitted override codes.
  • A resolved GPI. The 407-D7 NDC has already been mapped to a 14-digit Generic Product Identifier through the NDC-to-GPI Crosswalk Automation pipeline. ST/PA policy is keyed on GPI, not NDC, because clinical edit groups, step sequences, and brand-to-generic equivalencies are defined at the therapeutic-class level. Evaluating triggers on a raw NDC produces false positives every time a package-size or repackager NDC drifts.
  • A versioned formulary snapshot. Which GPIs require step therapy or prior authorization is read from an immutable, signed formulary snapshot carrying a monotonically increasing version. Reading policy from a live mutable table would let an in-flight claim observe a half-applied update — unreproducible in a payer audit.
  • A fill-history source. Step therapy needs prior utilization for the member within the lookback window (typically 90–365 days). This is a time-partitioned store keyed by tokenized member identifier and therapeutic class, not a full claims history materialized into memory.
  • Library baseline. The reference code targets Python 3.11+, pydantic>=2.0 for schema enforcement, structlog for JSON telemetry, and a strict 150ms internal budget so the end-to-end claim stays under the 200ms counter SLA. Any external history or PA-status lookup sits behind a circuit breaker that falls through to Fallback Routing Logic Design.

Trigger Rule Specification: Gates, Fields, and Reject Codes

The two gates read distinct NCPDP fields, consult distinct policy sources, and map to distinct NCPDP reject codes when they fire. Specifying them as a table — rather than as scattered if statements — is what keeps them auditable and testable against known fixtures.

Gate Trigger condition NCPDP field(s) Valid override Outcome on breach Reject / pend code
Step therapy Target GPI requires a prior trial that is absent from the lookback window 407-D7 NDC → GPI, 401-D1 Date of Service Submitted Clarification Code (420-DK) 1/2/3 STEP_THERAPY_REQUIRED 608 Step Therapy Required
Prior authorization Target GPI is flagged PA-required and no active authorization is on file 407-D7 NDC → GPI, 411-DB Prescriber ID Prior Auth Number Submitted (462-EV) present + active PRIOR_AUTH_REQUIRED (pend) 75 Prior Authorization Required
Combined ST + PA GPI requires both; ST cleared or overridden, PA still unsatisfied both of the above both overrides PRIOR_AUTH_REQUIRED 75 after 608 clears
Missing PA number PA-required GPI with a malformed/expired 462-EV 462-EV PA Number active authorization REJECTED 76 Plan Limitations / 75

Three design rules keep the specification deterministic:

  1. Fixed gate order. Step therapy is evaluated before prior authorization. A drug that requires both must clear (or validly override) ST first; only then does PA run. This ordering makes the emitted reject codes reproducible — a claim never sees a 75 while it still owes a 608.
  2. Version-stamped policy. Every trigger decision carries the formulary version it was read from, so a replayed claim reproduces the exact policy that was live at adjudication time.
  3. Read-only clinical context. The gate produces a decision object; it never writes back to accumulators or benefit state. Copay and accumulator math is owned downstream by Quantity Limit & Days Supply Validation and tier mapping, and a mutation here would make those calculations order-dependent.

Where those numeric boundaries themselves are calibrated — how many prior trials a step sequence demands, how long the lookback runs — is the province of Rule Engine Threshold Tuning & Optimization; this page concerns how a breached trigger becomes a correct, PHI-safe reject or pend.

Step therapy and prior authorization gate pipeline A normalized claim carrying the 407-D7 NDC resolved to GPI, the 401-D1 date of service, and submitted override codes enters a read-only clinical context. Inside that boundary two ordered gates run. The step therapy gate checks the lookback history and accepts a 420-DK clarification override; on breach it emits NCPDP reject 608 Step Therapy Required. A cleared or overridden claim passes to the prior authorization gate, which checks for an active 462-EV authorization on file; on breach it emits NCPDP pend 75 Prior Authorization Required. A claim clearing both gates passes to tier mapping. The clinical context emits a decision object only and never writes copay or accumulator state, which is owned downstream. READ-ONLY CLINICAL CONTEXT emits a decision object — never writes copay / accumulator state Normalized claim 407-D7→GPI · 401-D1 · overrides STEP THERAPY lookback history check override 420-DK 1/2/3 PRIOR AUTH active 462-EV check authorization on file Pass to tier mapping cleared / authorized copay + accumulator math Reject 608 Step Therapy Required Pend 75 Prior Auth Required cleared / overridden authorized no prior trial no active auth

Figure: The two ordered gates inside a read-only clinical context. Step therapy runs first, emitting NCPDP 608 on a missing prior trial; only a cleared or overridden claim reaches the prior-auth gate, which pends 75 when no active 462-EV authorization is on file. Copay and accumulator math live downstream, outside the boundary.

Reference Python Implementation

The pipeline below decouples validation, the two clinical gates, and the async batch fan-out. Policy is injected as a versioned snapshot rather than hardcoded, so changing which GPIs require ST/PA is a data change, not a code deploy. The telemetry layer emits only non-PHI identifiers — the GPI, the formulary version, and an opaque transaction_id — never the 302-C2 Cardholder ID, the member name, or any raw claim bytes. There is no monetary math in this stage, so no Decimal fields appear here; when a breached trigger hands off to copay logic downstream, every monetary value there uses decimal.Decimal, never float.

python
import asyncio
import structlog
from datetime import datetime, timezone
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator

# JSON telemetry only — structured fields, never raw claim bytes or PHI.
structlog.configure(
    processors=[
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.stdlib.BoundLogger,
    cache_logger_on_first_use=True,
)
logger = structlog.get_logger()


class AdjudicationStatus(str, Enum):
    APPROVED = "APPROVED"
    STEP_THERAPY_REQUIRED = "STEP_THERAPY_REQUIRED"
    PRIOR_AUTH_REQUIRED = "PRIOR_AUTH_REQUIRED"
    OVERRIDE_ACCEPTED = "OVERRIDE_ACCEPTED"
    REJECTED = "REJECTED"


class NcpdpClaim(BaseModel):
    # PHI (302-C2 Cardholder ID, 310-CA Patient Name) was stripped at ingestion.
    # The gate sees only an opaque transaction_id plus the fields it evaluates.
    transaction_id: str = Field(..., alias="TransactionID")        # opaque, non-PHI
    member_token: str = Field(..., alias="MemberToken")            # hashed member key for history lookup
    gpi: str = Field(..., alias="GPI", min_length=14, max_length=14)  # from 407-D7 NDC crosswalk
    prescriber_npi: str = Field(..., alias="PrescriberNPI")        # 411-DB Prescriber ID
    date_of_service: str = Field(..., alias="DateOfService")       # 401-D1 Date of Service (ISO)
    st_override_code: Optional[str] = Field(None, alias="ClarificationCode")  # 420-DK submitted override
    pa_number: Optional[str] = Field(None, alias="PriorAuthNumber")           # 462-EV PA Number Submitted

    @field_validator("gpi")
    @classmethod
    def validate_gpi_format(cls, v: str) -> str:
        if not (v.isdigit() and len(v) == 14):
            raise ValueError("GPI must be exactly 14 numeric digits")
        return v


class StepTherapyRule(BaseModel):
    # Injected from a versioned, signed formulary snapshot — never hardcoded.
    rule_id: str
    target_gpi: str
    required_sequence: List[str]  # prerequisite GPIs that must appear in history
    requires_pa: bool = False


class AdjudicationResult(BaseModel):
    transaction_id: str
    status: AdjudicationStatus
    reject_codes: List[str] = []
    snapshot_version: int
    rule_ids_triggered: List[str] = []
    evaluated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class STPAAdjudicator:
    def __init__(
        self,
        st_rules: List[StepTherapyRule],
        pa_gpis: List[str],
        snapshot_version: int,
    ):
        self.st_rules = {r.target_gpi: r for r in st_rules}
        self.pa_gpis = set(pa_gpis)
        self.snapshot_version = snapshot_version
        self.log = structlog.get_logger(component="st_pa_engine")

    async def _history_has(self, member_token: str, gpis: List[str]) -> bool:
        # Time-partitioned lookback query keyed by hashed member token + GPI.
        # Wrapped by the caller in a circuit breaker; never blocks the hot path.
        history = await fetch_utilization(member_token, self.snapshot_version)
        return any(g in history for g in gpis)

    async def _pa_active(self, member_token: str, gpi: str, pa_number: Optional[str]) -> bool:
        # 462-EV must be present AND resolve to an active authorization on file.
        if not pa_number:
            return False
        return await pa_status_active(member_token, gpi, pa_number)

    async def evaluate_claim(self, claim: NcpdpClaim) -> AdjudicationResult:
        triggered: List[str] = []
        reject_codes: List[str] = []
        status = AdjudicationStatus.APPROVED

        # --- Gate 1: Step therapy (evaluated first, always) ---
        st_rule = self.st_rules.get(claim.gpi)
        if st_rule is not None:
            triggered.append(st_rule.rule_id)
            has_prereq = await self._history_has(claim.member_token, st_rule.required_sequence)
            if has_prereq:
                pass  # prior trial satisfied on its own merits
            elif claim.st_override_code in ("1", "2", "3"):  # 420-DK clinical override
                status = AdjudicationStatus.OVERRIDE_ACCEPTED
                self.log.info("st_override_accepted", transaction_id=claim.transaction_id,
                              gpi=claim.gpi, code=claim.st_override_code)
            else:
                status = AdjudicationStatus.STEP_THERAPY_REQUIRED
                reject_codes.append("608")  # NCPDP Reject 608: Step Therapy Required
                self.log.warning("st_gate_triggered", transaction_id=claim.transaction_id,
                                 gpi=claim.gpi, snapshot_version=self.snapshot_version)

        # --- Gate 2: Prior authorization (only if ST cleared or was overridden) ---
        st_requires_pa = st_rule.requires_pa if st_rule else False
        pa_required = claim.gpi in self.pa_gpis or st_requires_pa
        if status in (AdjudicationStatus.APPROVED, AdjudicationStatus.OVERRIDE_ACCEPTED) and pa_required:
            if await self._pa_active(claim.member_token, claim.gpi, claim.pa_number):
                status = AdjudicationStatus.OVERRIDE_ACCEPTED
                self.log.info("pa_authorization_active", transaction_id=claim.transaction_id, gpi=claim.gpi)
            else:
                status = AdjudicationStatus.PRIOR_AUTH_REQUIRED
                reject_codes.append("75")  # NCPDP Reject 75: Prior Authorization Required
                self.log.warning("pa_gate_triggered", transaction_id=claim.transaction_id,
                                 gpi=claim.gpi, snapshot_version=self.snapshot_version)

        return AdjudicationResult(
            transaction_id=claim.transaction_id,
            status=status,
            reject_codes=reject_codes,
            snapshot_version=self.snapshot_version,
            rule_ids_triggered=triggered,
        )


async def process_claim_batch(
    claims: List[NcpdpClaim],
    adjudicator: STPAAdjudicator,
) -> List[AdjudicationResult]:
    # Bounded fan-out; fail one claim closed without failing the batch.
    results = await asyncio.gather(
        *(adjudicator.evaluate_claim(c) for c in claims),
        return_exceptions=True,
    )
    out: List[AdjudicationResult] = []
    for claim, res in zip(claims, results):
        if isinstance(res, Exception):
            # Fail closed to REJECTED; log the error TYPE only, never the payload.
            logger.error("adjudication_failure", transaction_id=claim.transaction_id,
                         error=type(res).__name__)
            out.append(AdjudicationResult(
                transaction_id=claim.transaction_id,
                status=AdjudicationStatus.REJECTED,
                reject_codes=["76"],  # fail-closed sentinel
                snapshot_version=adjudicator.snapshot_version,
            ))
            continue
        out.append(res)
    return out

Because the rule set and snapshot_version are injected, a policy change is a new snapshot record, not a redeploy — and every emitted result carries the version that produced it, satisfying payer audit replay requirements.

Step therapy and prior authorization branch decision tree A claim with a resolved GPI enters. First decision: does the drug require step therapy? If no, the flow skips to the prior-authorization decision. If yes, the engine checks whether a valid 420-DK override code 1, 2, or 3 was submitted; if not, it rejects with NCPDP code 608 Step Therapy Required, and if so it proceeds. The prior-authorization decision asks whether the drug requires prior auth; if no, the claim is approved and passed to tier mapping. If yes, the engine checks for an active 462-EV authorization on file; if none, it pends with NCPDP code 75 Prior Authorization Required, and if present the claim is approved. The spine represents a drug requiring both gates; the left bypass edges represent claims that require neither. Claim with resolved GPI Requires step therapy? ST override 420-DK 1/2/3? Requires prior authorization? Active 462-EV authorization? Approve pass to tier mapping Reject 608 Step Therapy Required Pend 75 Prior Auth Required yes override valid yes active no no no ST no PA

Figure: The full branch tree. The vertical spine is the worst case — a drug gated by both step therapy and prior auth — clearing an override at each stage; the dashed left edges are the bypasses taken when a gate does not apply. Breaches exit right to NCPDP 608 (step therapy) and 75 (prior authorization).

Engineering Constraints & Known Failure Modes

ST/PA logic sits on a narrow ledge: too strict and it blocks legitimate therapy at the counter, too loose and it authorizes non-preferred agents the plan meant to gate. The failure modes below are the ones that actually reach production incident reviews.

  • GPI gaps and stale mappings. If the 407-D7 NDC failed to resolve to a GPI, the gate evaluates against the wrong step sequence or none at all. Treat an unresolved GPI as a hard stop — reject with 70 Product/Service Not Covered or 75 per plan policy rather than falling through to APPROVED. Never let a missing key take the permissive branch.
  • Plan-override conflicts. A submitted 420-DK Clarification Code that is valid syntactically but not authorized for that GPI must not silently pass ST. Validate the override against the policy that requires it, not just against the set {1, 2, 3}, or an operator learns to blanket-submit 1 to bypass every step edit.
  • PA pend races. Two claims for the same member and GPI can arrive while a PA is mid-approval; both read “no active authorization” and both emit 75, generating duplicate pend records and duplicate prescriber outreach. Serialize PA-status reads per (member_token, gpi) with an idempotency key so a retried or concurrent claim reproduces one pend, not two.
  • Reject-vs-pend confusion. A 608 step-therapy result is a hard reject the pharmacy can act on; a 75 prior-auth result is a pend awaiting review. Emitting a reject where the plan expects a pend (or vice versa) breaks pharmacy-side messaging and inflates helpdesk volume, mirroring the categorization discipline in Schema Validation & Error Categorization.
  • Lookback-window drift. History queries that silently return a truncated window make a member with a genuine prior trial look step-naive, producing a false 608. Pin the lookback bounds to the snapshot version and assert the window on every read.
  • External-call timeouts inside the gate. History and PA-status lookups can stall the worker. Wrap them in a circuit breaker with a strict sub-150ms budget and route degraded claims through Fallback Routing Logic Design; timeouts go to a dead-letter queue for asynchronous reconciliation rather than blocking adjudication.
  • PHI leakage in debug telemetry. The single most common compliance defect is logging a raw payload while chasing a false trigger. Log only the GPI, transaction_id, snapshot version, and outcome — the boundaries enforced in Security & Compliance Boundaries for Claims Data apply to every ST/PA dashboard and log sink.

Performance & Correctness Tuning

The gate runs on every claim, so its per-call cost multiplies across peak dispensing volume. Several patterns keep it fast without sacrificing correctness:

  • Cache the policy, not the decision. Load the versioned rule set and the PA-required GPI set into an in-memory map keyed by snapshot_version, refreshed only when a new snapshot publishes. Never cache the per-claim outcome — fill history changes it between calls.
  • Idempotency keys. Key each evaluation on (transaction_id, snapshot_version) so a retried claim after a transient failure produces an identical result and cannot double-pend a prior authorization.
  • Bounded async fan-out. The asyncio.gather batch above overlaps the I/O-bound history and PA lookups, but keep the CPU work per gate trivial — a set membership test and a dict lookup, never heavy parsing in the hot path.
  • Fail closed, log the type. On any exception, return REJECTED with a sentinel code and log the exception type only. A gate that fails open authorizes a gated drug; a gate that leaks the payload while failing breaks HIPAA.
  • SLA headroom. Reserve the gap between the 150ms internal budget and the 200ms counter SLA for serialization and response formatting; measure the p99 of gate latency, not the mean, because tail latency is what breaches the counter.

Deep-Dive Implementations

This gate connects to the concrete rule code that implements each branch. The deep dive below carries the runnable evaluation logic in full:

← Back to Formulary Validation & Rule Engine Design