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-D7 Product/Service ID (the 11-digit NDC), 442-E7 Quantity Dispensed, 405-D5 Days Supply, 460-ET Quantity Prescribed, and 600-28 Unit of Measure. The 302-C2 Cardholder ID and 310-CA Patient 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.0 for inbound validation (@field_validator), and decimal.Decimal for 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:

code
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_consumption

A 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.

python
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.

Quantity-limit and days-supply validation decision flow A normalized claim enters. The engine derives implied days supply from the SIG interval, falling back to the submitted 405-D5 value when the SIG is unstructured. It then runs two ordered checks. First the quantity-limit check: if dispensed quantity exceeds the snapshot max_quantity, an override-eligible maintenance GPI routes to the clinical gate as prior-auth reject 75, otherwise it is a hard reject 76 Plan Limitations Exceeded. If quantity is within limit, the days-supply check runs: effective days supply over max_days_supply is a hard reject AG Days Supply Limitation. A claim passing both checks is within QL and DS limits and proceeds. Normalized claim in Derive implied days from SIG interval fall back to submitted 405-D5 Dispensed qty > max_quantity? Effective days > max_days_supply? Within QL / DS limits proceed to copay math Override-eligible maintenance GPI? Hard reject — Plan Limits reject code 76 Route to clinical gate prior auth · reject code 75 Hard reject — Days Supply reject code AG within within exceeds no yes exceeds

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 ML against a plan limit expressed in TAB produces a nonsensical comparison. The 600-28 Unit 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_UP at the final step (never mid-calculation) keeps a 28-day inhaler from rounding to 30 and tripping a AG reject 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-D5 rather 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-D7 NDC 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 75 Prior Authorization Required where a hard 76/AG is 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 in 76 rejections 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_PATTERN are 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 Decimal initialized 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-identical ValidationResult. 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 version for audit.
  • Backpressure on dependency stalls. When an override lookup must reach an external system, it sits behind the latency budget and 404/503 handling defined in PBM API Sync & Rate Limiting so a slow dependency degrades to a fallback tier instead of stretching the claim past its SLA.

← Back to Formulary Validation & Rule Engine Design