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-C2Cardholder ID and310-CAPatient First Name — at the edge, replacing them with an opaquetransaction_id. The gate reads only the fields it needs:407-D7Product/Service ID (NDC),411-DBPrescriber ID (NPI),401-D1Date of Service, plus the submitted override codes. - A resolved GPI. The
407-D7NDC 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.0for schema enforcement,structlogfor 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:
- 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
75while it still owes a608. - Version-stamped policy. Every trigger decision carries the formulary
versionit was read from, so a replayed claim reproduces the exact policy that was live at adjudication time. - 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.
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.
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 outBecause 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.
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-D7NDC 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 with70Product/Service Not Covered or75per plan policy rather than falling through toAPPROVED. Never let a missing key take the permissive branch. - Plan-override conflicts. A submitted
420-DKClarification 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-submit1to 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
608step-therapy result is a hard reject the pharmacy can act on; a75prior-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.gatherbatch 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
REJECTEDwith 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:
- Building step therapy logic gates in Python adjudication scripts — a stateful, PHI-safe step-therapy gate that streams claims, filters the
401-D1lookback window, and maps a missing prior trial to a608reject code.
Related
- Formulary Validation & Rule Engine Design — the parent domain this gate lives in.
- Tier Mapping & Copay Calculation Logic — the financial stage a cleared or overridden claim hands off to.
- Quantity Limit & Days Supply Validation — the utilization gate that runs alongside these clinical triggers.
- Rule Engine Threshold Tuning & Optimization — where the step-count and lookback boundaries this gate enforces are calibrated.
- NDC-to-GPI Crosswalk Automation — the upstream resolution that makes GPI-keyed triggers trustworthy.
- Fallback Routing Logic Design — the degraded-path routing this gate falls through to on lookup timeout.