Quantity Limit & Days Supply Validation
Quantity limit (QL) and days supply (DS) validation is the utilization-management gate that decides, deterministically, whether a claim’s dispensed amount is payable before any money is calculated. It sits inside the Formulary Validation & Rule Engine Design layer, immediately after the drug is confirmed active on formulary and immediately before clinical gates and copay math run. A QL/DS failure must short-circuit the pipeline and emit reject code 76 Plan Limitations Exceeded rather than letting a non-payable claim reach the financial stage — get the ordering wrong and the engine quotes a member cost-share for a fill that was never allowed. This page specifies the rule set, a production-grade Python implementation, and the failure modes that make QL/DS the most reject-heavy stage in adjudication.
Prerequisites
QL/DS validation is a pure function of a normalized claim and a signed formulary snapshot; it does not read the network on the hot path. Before this workflow runs, the following must already be true:
- The claim is normalized and PHI-tokenized. Ingestion via NCPDP D.0 message parsing has produced a typed claim carrying
407-D7Product/Service ID (the 11-digit NDC),442-E7Quantity Dispensed,405-D5Days Supply,460-ETQuantity Prescribed, and600-28Unit of Measure. The302-C2Cardholder ID and310-CAPatient Name are already tokenized or stripped — they never reach this stage as raw values. - The NDC is resolved to a GPI. The NDC-to-GPI Crosswalk Automation pipeline has attached the 14-digit Generic Product Identifier so limits can be evaluated against a therapeutic-class record, not just a single package NDC.
- A versioned formulary snapshot is loaded. The plan’s
max_quantity,max_days_supply, and per-GPI QL overrides are read from an immutable, signed snapshot so any historical decision is replayable in a payer audit. Never evaluate against a live mutable table. - A UOM normalization table is warm. Manufacturer units (
TAB,ML,GM,EA) are mapped to the CMS-standard billing unit ahead of time so no conversion allocates on the hot path. - Library baseline. Python 3.11+,
pydantic>=2.0for inbound validation (@field_validator), anddecimal.Decimalfor every quantity and money value. Schema-shape failures are routed to the dead-letter path described in Schema Validation & Error Categorization rather than crashing the adjudication thread.
Rule Specification
The engine derives an implied days supply from the prescriber’s SIG (directions) and compares both the dispensed quantity and the derived days supply against plan maximums. Two independent checks run, each with its own reject mapping. The dispensed-quantity check is authoritative for QL; the SIG-derived days supply is used when it is available and falls back to the submitted 405-D5 value when the SIG is unstructured.
| Condition | NCPDP field(s) evaluated | Outcome | Reject code |
|---|---|---|---|
442-E7 normalized qty ≤ plan max_quantity |
442-E7, 600-28 |
passes QL check | — |
442-E7 normalized qty > plan max_quantity |
442-E7, 407-D7 |
hard reject | 76 Plan Limitations Exceeded |
Effective days supply ≤ plan max_days_supply |
405-D5, SIG-derived |
passes DS check | — |
Effective days supply > plan max_days_supply |
405-D5 |
hard reject | AG Days Supply Limitation For Product/Service |
| Overage on a maintenance/90-day GPI with override eligibility | 442-E7, 460-ET |
route to clinical gate | 75 Prior Authorization Required |
Qty ≥ 80% of max_quantity on a maintenance fill |
442-E7 |
pass, flag for tier review | — (annotate result) |
Days supply is derived, not trusted blindly, because a submitted 405-D5 can disagree with the dispensed quantity and the dosing directions. The derivation normalizes the SIG interval to a common hour base, computes daily consumption, and divides the dispensed quantity by it:
admins_per_day = 24 / interval_hours # e.g. "q8h" -> 24/8 = 3
daily_consumption = admins_per_day * dose_per_admin
implied_days_supply = dispensed_qty / daily_consumptionA tablet dispensed 90 count with a SIG of “take 1 tablet every 8 hours” implies 90 / 3 = 30 days, regardless of what 405-D5 claims. When the derived value and the submitted value disagree materially, the derived value governs the DS check and the discrepancy is annotated for audit. Overage does not always mean a hard reject: a legitimate clinical exception routes to Step Therapy & Prior Auth Trigger Rules for justification (75), which lets valid high-quantity prescriptions bypass a rigid limit while preserving the audit trail.
Reference Python Implementation
The implementation below validates the inbound claim with Pydantic v2 (field-level 442-E7/405-D5 constraints), derives days supply from a pre-compiled SIG parser, and evaluates both limits deterministically. Every quantity is decimal.Decimal — never float — so unit conversions and 80% thresholds cannot introduce sub-unit drift that later shows up as a reconciliation defect. Telemetry is structured and PHI-safe: it logs the tokenized member reference, the GPI, the rule path, and the snapshot version, and never the raw claim bytes or the 302-C2/310-CA fields.
from __future__ import annotations
import re
import logging
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
# Structured logs only: tokenized member ref, GPI, rule path, snapshot version.
# NEVER log raw claim bytes or the 302-C2 Cardholder ID / 310-CA Patient Name.
logger = logging.getLogger("pbm.ql_ds_validator")
class RejectCode(str, Enum):
PLAN_LIMITATIONS_EXCEEDED = "76" # dispensed qty over plan max_quantity
DAYS_SUPPLY_LIMITATION = "AG" # effective days supply over max_days_supply
PRIOR_AUTH_REQUIRED = "75" # overage eligible for override -> clinical gate
PAID = "00"
# Convert a parsed SIG interval unit to a common hour base.
_INTERVAL_UNIT_HOURS = {"hour": 1, "hr": 1, "h": 1, "day": 24, "d": 24, "week": 168, "wk": 168}
# Pre-compiled at import: "take 1 tablet every 8 hours", "instill 2 drops q6h", etc.
SIG_PATTERN = re.compile(
r"(?:take|apply|instill|inhale)\s+(\d+)\s*"
r"(?:tablet|capsule|drop|puff|ml|mg)?\s*(?:every|q)\s*(\d+)\s*"
r"(hour|hr|h|day|d|week|wk)?",
re.IGNORECASE,
)
# Manufacturer UOM -> CMS-standard billing-unit multiplier (600-28 Unit of Measure).
_UOM_MULTIPLIER = {
"TAB": Decimal("1"), "EA": Decimal("1"),
"ML": Decimal("1"), "GM": Decimal("1"),
}
class Claim(BaseModel):
"""Normalized, PHI-tokenized claim slice the QL/DS stage consumes."""
ndc: str = Field(..., alias="407-D7", min_length=11, max_length=11) # Product/Service ID
gpi: str = Field(..., alias="GPI", min_length=14, max_length=14) # resolved via crosswalk
dispensed_qty: Decimal = Field(..., alias="442-E7", gt=0) # Quantity Dispensed
dispensed_uom: str = Field(..., alias="600-28") # Unit of Measure
submitted_days_supply: int = Field(..., alias="405-D5", ge=1, le=365) # Days Supply
sig: str = Field("", alias="SIG")
member_token: str = Field(..., alias="member_token") # tokenized 302-C2 — NOT the raw value
@field_validator("ndc")
@classmethod
def ndc_is_11_digits(cls, v: str) -> str:
if not v.isdigit():
raise ValueError("407-D7 NDC must be 11 unhyphenated digits")
return v
class DrugLimits(BaseModel):
"""Per-GPI limits read from the signed, versioned formulary snapshot."""
gpi: str
max_quantity: Decimal
max_days_supply: int
is_maintenance: bool
override_eligible: bool
class ValidationResult(BaseModel):
status: RejectCode
snapshot_version: str
applied_qty: Decimal
effective_days_supply: int
route_to_clinical_gate: bool = False
annotations: list[str] = Field(default_factory=list)
audit: str = ""
def _normalize_qty(qty: Decimal, uom: str) -> Decimal:
"""Convert 442-E7 to the CMS-standard billing unit (600-28 aware)."""
mult = _UOM_MULTIPLIER.get(uom.upper())
if mult is None:
raise ValueError(f"Unsupported 600-28 Unit of Measure: {uom}")
return (qty * mult).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
def _derive_days_supply(sig: str, qty: Decimal) -> Optional[int]:
"""Derive days supply from the SIG; None when the SIG is unstructured."""
match = SIG_PATTERN.search(sig or "")
if not match:
return None
dose_per_admin = Decimal(match.group(1))
interval_value = int(match.group(2))
unit = (match.group(3) or "hour").lower()
interval_hours = interval_value * _INTERVAL_UNIT_HOURS.get(unit, 1)
if interval_hours <= 0 or dose_per_admin <= 0:
return None
admins_per_day = Decimal("24") / Decimal(interval_hours)
daily_consumption = admins_per_day * dose_per_admin
if daily_consumption <= 0:
return None
implied = qty / daily_consumption
return int(implied.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def validate_ql_ds(claim: Claim, limits: DrugLimits, snapshot_version: str) -> ValidationResult:
"""Deterministic QL/DS gate. Runs before clinical gates and copay math."""
qty = _normalize_qty(claim.dispensed_qty, claim.dispensed_uom)
derived = _derive_days_supply(claim.sig, qty)
effective_ds = derived if derived is not None else claim.submitted_days_supply
annotations: list[str] = []
if derived is not None and abs(derived - claim.submitted_days_supply) > 3:
annotations.append(
f"405-D5 mismatch: submitted={claim.submitted_days_supply} derived={derived}"
)
def _log(path: str) -> None:
# Tokenized reference + rule path only — no PHI, no raw payload.
logger.info("ql_ds", extra={"member_ref": claim.member_token, "gpi": claim.gpi,
"path": path, "snapshot_version": snapshot_version})
# 1. Quantity-limit check (442-E7 vs snapshot max_quantity).
if qty > limits.max_quantity:
if limits.is_maintenance and limits.override_eligible:
_log("QL_OVERAGE_TO_PA")
return ValidationResult(
status=RejectCode.PRIOR_AUTH_REQUIRED, snapshot_version=snapshot_version,
applied_qty=limits.max_quantity, effective_days_supply=effective_ds,
route_to_clinical_gate=True, annotations=annotations,
audit=f"QL overage {qty}>{limits.max_quantity}; override-eligible -> PA (75)")
_log("QL_HARD_REJECT")
return ValidationResult(
status=RejectCode.PLAN_LIMITATIONS_EXCEEDED, snapshot_version=snapshot_version,
applied_qty=limits.max_quantity, effective_days_supply=effective_ds,
annotations=annotations,
audit=f"QL exceeded: {qty} > max {limits.max_quantity} (76)")
# 2. Days-supply check (effective vs snapshot max_days_supply).
if effective_ds > limits.max_days_supply:
_log("DS_HARD_REJECT")
return ValidationResult(
status=RejectCode.DAYS_SUPPLY_LIMITATION, snapshot_version=snapshot_version,
applied_qty=qty, effective_days_supply=limits.max_days_supply,
annotations=annotations,
audit=f"DS exceeded: {effective_ds} > max {limits.max_days_supply} (AG)")
# 3. Soft flag: high-volume maintenance fills for downstream tier review.
if limits.is_maintenance and qty >= (limits.max_quantity * Decimal("0.8")):
annotations.append("high-volume maintenance fill flagged for tier review")
_log("PAID")
return ValidationResult(
status=RejectCode.PAID, snapshot_version=snapshot_version,
applied_qty=qty, effective_days_supply=effective_ds, annotations=annotations,
audit="within QL/DS limits")The gate returns a ValidationResult stamped with snapshot_version on every branch, so no decision — approve, reject, or route-to-gate — is ever emitted without naming the formulary version that produced it. Downstream stages consume applied_qty and effective_days_supply directly; they never re-derive from the raw payload.
Figure: The two ordered gates — quantity limit then days supply — deriving implied days from the hour-normalized SIG before comparing against the signed snapshot's maxima. A QL overage on an override-eligible maintenance GPI routes to the clinical gate (75) rather than hard-rejecting (76); a days-supply overage is a hard AG reject.
Engineering Constraints & Known Failure Modes
QL/DS is where the largest share of production rejections originate, so its edge cases are worth enumerating precisely:
- UOM mismatch. A claim billed in
MLagainst a plan limit expressed inTABproduces a nonsensical comparison. The600-28Unit of Measure must be reconciled to the snapshot’s unit before comparison; an unknown UOM raises rather than silently defaulting to a 1:1 multiplier, and the raise is categorized as a schema fault, not a limit reject. - Fractional and split-package dispensing. Insulin vials, inhalers, and titration packs produce fractional daily consumption. Rounding the implied days supply with
ROUND_HALF_UPat the final step (never mid-calculation) keeps a 28-day inhaler from rounding to 30 and tripping aAGreject that a member would experience as a counter rejection. - Unstructured SIG. “Use as directed” yields no parse. The engine falls back to the submitted
405-D5rather than rejecting — a missing derivation is not a limit violation. Silent fallback is annotated so audit can distinguish a derived pass from a trusted-submission pass. - NDC gaps against a stale snapshot. If the
407-D7NDC resolves to a GPI with no limit record in the loaded snapshot, the claim must not default to “no limit.” It routes to Fallback Routing Logic Design with a defined conservative limit, and the snapshot desync is alerted. - Plan override conflicts. A member-specific QL override and a group-level limit can disagree. Precedence is resolved deterministically from the snapshot (member override wins, then group, then plan default) so the same claim never adjudicates two ways on two nodes.
- Reject-code drift. Emitting
75Prior Authorization Required where a hard76/AGis warranted (or vice-versa) corrupts pharmacy-counter messaging. The reject mapping is table-driven and unit-tested against fixed fixtures so a code change is a data change, not a scattered edit. A sudden surge in76rejections almost always signals a bad quantity-limit push in a new snapshot rather than a change in prescribing.
Performance & Correctness Tuning
To hold the sub-200ms adjudication SLA, QL/DS must add no network round-trips and no per-claim allocation on the hot path:
- Warm, immutable lookups. The per-GPI limit table, the UOM multipliers, and the compiled
SIG_PATTERNare loaded once at snapshot swap and held as immutable structures. A cache-aside warm-up runs during the deployment window so the first claim after a snapshot cutover pays no cold-cache penalty. Threshold calibration for these tables is covered in Rule Engine Threshold Tuning & Optimization. - Decimal discipline. Every quantity, multiplier, and 80%-threshold value is
Decimalinitialized from a string. This is the same money-safety rule the copay stage relies on, and it is what makes the QL/DS output safe to feed straight into Tier Mapping & Copay Calculation Logic without re-parsing. - Idempotency. Keyed by
(transaction_id, snapshot_version), a re-submitted claim re-evaluates to a byte-identicalValidationResult. Because the gate is a pure function of claim plus snapshot, replaying an audit produces the same reject code every time — the property payers require to reconcile a disputed adjudication. - Snapshot-versioned everything. A tier or limit is only ever valid relative to a snapshot version. Hot-swapping a new snapshot alongside the current one and cutting over atomically means an in-flight claim finishes on the version it started on, and every swap is logged with old and new
versionfor audit. - Backpressure on dependency stalls. When an override lookup must reach an external system, it sits behind the latency budget and
404/503handling defined in PBM API Sync & Rate Limiting so a slow dependency degrades to a fallback tier instead of stretching the claim past its SLA.
Related
- Formulary Validation & Rule Engine Design — the parent rule engine that runs this gate between formulary lookup and copay calculation.
- Step Therapy & Prior Auth Trigger Rules — where override-eligible QL overage routes for clinical justification (
75). - Tier Mapping & Copay Calculation Logic — consumes the validated quantity and days supply to derive member cost-share.
- Rule Engine Threshold Tuning & Optimization — how the QL/DS thresholds themselves are calibrated against live telemetry.
- NDC-to-GPI Crosswalk Automation — resolves the
407-D7NDC to the GPI that keys every limit record. - Schema Validation & Error Categorization — where malformed UOM and out-of-range fields are quarantined before this gate runs.