Syncing PA Appeal Status Across Systems

A prior-authorization appeal outcome is decided in one system but has to be true in three: the adjudication engine that rejected the claim, the PA case-management system where reviewers work, and the member and prescriber portals that display status. The decision this page settles is how to keep those views consistent when they cannot share a single database — how to ingest status and appeal updates idempotently, tolerate out-of-order delivery, and reconcile divergence without ever letting a stale event overwrite a newer one. The concrete hazard is a denied-then-overturned appeal where the “overturned → approved” update lands before the earlier “denied” update finishes propagating, and a naive last-write-wins consumer flips the member back to denied. The fix is a version-vectored, idempotent apply, and the sections below compare the transport options and implement the consumer. This is the cross-system consistency layer the Prior Authorization Routing Automation state machine depends on, and it shares its reconciliation discipline with PBM Portal Sync Architecture.

The Sync Transport Decision

Three transports move status across the boundary, and they differ in latency, delivery guarantee, and how much out-of-order handling they push onto the consumer. None of them removes the need for an idempotent, version-checked apply — they only change how events arrive.

Transport How updates propagate Latency Delivery / ordering Consumer burden
Polling Consumer periodically reads PA status by case_id Seconds to minutes At-least-once; consumer sees latest snapshot Low ordering risk, high staleness and read load
Webhook PA system POSTs status on change Low (sub-second) At-least-once; retries reorder; no global order Must dedup and version-check every callback
Event stream Ordered log (Kafka/Kinesis) of status events Low At-least-once within a partition; per-key order if partitioned by case_id Idempotent apply; cross-partition order still not guaranteed

The production choice is an event stream partitioned by case_id, so all events for one case land on one partition in commit order, with polling reconciliation as a backstop for missed events. But partitioning gives per-case ordering only if every producer keys on case_id; a webhook fan-out or a cross-partition replay can still deliver a v2 after a v3. So the consumer must treat every update as potentially stale and apply it only if it advances the case’s version vector. That single rule — apply if and only if the incoming version dominates the applied version — makes ingestion correct under any of the three transports, which is why it, not the transport, is the load-bearing decision.

Idempotent, version-vectored PA appeal status sync The PA system is the source of truth and publishes status events to an event stream, which fans out to the adjudication engine and to member and prescriber portals. Below, the idempotent apply logic is shown: a v3 APPROVED event and a late-arriving v2 DENIED event both reach a version-vector guard that asks whether the incoming version exceeds the applied version. The v3 event is applied and advances the vector; the stale v2 event is dropped. The consumers converge on the APPROVED state regardless of delivery order. PA system source of truth Status event stream partition by case_id Adjudication engine re-price or reject Member / prescriber portals Idempotent apply · version-vector guard v3 · APPROVED v2 · DENIED (late) incoming > applied? Apply vector := v3 Drop · stale no state change Converged: APPROVED order-independent yes no

Figure: The PA system fans status out to the engine and portals; the version-vector guard applies the newer v3 event, drops the late v2, and every consumer converges on the same state regardless of arrival order.

Step-by-Step Implementation

The consumer is built in three moves: define a version-vectored status event, apply it idempotently against the last-applied vector, and record the result for reconciliation.

1. Model the status event with a version and an event id. Every event carries the case_id, the new state, a monotonically increasing version from the PA source of truth, and a unique event_id for dedup.

python
from enum import Enum
from typing import Optional
from pydantic import BaseModel, ConfigDict


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


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

    event_id: str            # idempotency key; unique per emitted change
    case_id: str             # partition/order key
    member_token: str        # opaque; 302-C2 never on this event
    state: PAState
    version: int             # monotonic per case, from the PA source of truth
    occurred_at: str

2. Apply idempotently against the version vector. The consumer keeps the last-applied version per case. An event whose version does not strictly exceed it is a duplicate or a stale reorder and is dropped; only an advancing version mutates state.

python
import json
import logging
from dataclasses import dataclass

logging.basicConfig(format="%(message)s", level=logging.INFO)
logger = logging.getLogger("pa_sync")


@dataclass
class CaseView:
    case_id: str
    state: PAState
    applied_version: int


@dataclass(frozen=True)
class ApplyResult:
    view: CaseView
    changed: bool
    reason: str


def apply_status(view: CaseView, event: StatusEvent) -> ApplyResult:
    """Idempotent, out-of-order-tolerant status apply."""
    if event.case_id != view.case_id:
        return ApplyResult(view, False, "case_mismatch")

    # Version-vector guard: apply only if the event advances the case.
    if event.version <= view.applied_version:
        _audit(view, event, changed=False, reason="stale_or_duplicate")
        return ApplyResult(view, False, "stale_or_duplicate")

    view.state = event.state
    view.applied_version = event.version
    _audit(view, event, changed=True, reason="applied")
    return ApplyResult(view, True, "applied")


def _audit(view: CaseView, event: StatusEvent, changed: bool, reason: str) -> None:
    # PHI GUARDRAIL: tokens + state + version only; never 302-C2 / 310-CA / payload.
    logger.info(json.dumps({
        "event": "pa_status_sync",
        "case_id": view.case_id,
        "member_token": event.member_token,
        "to_state": event.state.value,
        "event_version": event.version,
        "applied_version": view.applied_version,
        "changed": changed,
        "reason": reason,
    }))

3. Reconcile on a backstop. Because delivery is at-least-once and a partition can drop a consumer for a window, run a periodic poll that reads the authoritative (state, version) from the PA system and calls apply_status with it. Since the apply is version-guarded, a reconciliation event that matches the applied version is a harmless no-op, and one that advances it repairs a missed update — the same repair loop portals rely on.

Verification and Testing Pattern

Pin the two properties that matter: applying events out of order yields the same final state as applying them in order, and re-delivering an already-applied event changes nothing.

python
import pytest


def view():
    return CaseView(case_id="C1", state=PAState.PENDING, applied_version=0)


def ev(version, state):
    return StatusEvent(event_id=f"e{version}", case_id="C1",
                       member_token="MTOK_1", state=state,
                       version=version, occurred_at="2026-07-17T12:00:00Z")


def test_out_of_order_converges_to_latest():
    v = view()
    # v3 (APPROVED) arrives before v2 (DENIED); v2 must lose.
    apply_status(v, ev(3, PAState.APPROVED))
    apply_status(v, ev(2, PAState.DENIED))
    assert v.state == PAState.APPROVED
    assert v.applied_version == 3


def test_in_order_reaches_same_state():
    v = view()
    for e in [ev(1, PAState.APPEALED), ev(2, PAState.DENIED), ev(3, PAState.APPROVED)]:
        apply_status(v, e)
    assert v.state == PAState.APPROVED       # same terminal state as out-of-order run


def test_duplicate_delivery_is_noop():
    v = view()
    apply_status(v, ev(2, PAState.APPROVED))
    r = apply_status(v, ev(2, PAState.APPROVED))   # at-least-once redelivery
    assert r.changed is False
    assert v.applied_version == 2

test_out_of_order_converges_to_latest is the load-bearing case: it proves the late v2 DENIED cannot roll an already-APPROVED member backward, which is exactly the split-brain the version vector exists to prevent.

Gotchas and PHI Guardrails

  • Version source must be single. The monotonic version has to originate at the PA system of record, not be assigned per consumer; two consumers minting their own versions cannot be compared and reintroduce split-brain.
  • Cross-partition order is not free. Partitioning by case_id gives per-case order only if every producer keys on case_id. A webhook path or a replay that fans across partitions still needs the version guard — never assume transport ordering.
  • Reconciliation is mandatory, not optional. At-least-once streams drop events during rebalances. Without the polling backstop a permanently missed APPROVED leaves a member rejected forever; with it, the next poll repairs the view.
  • Idempotency on the write side too. If applying a status also triggers a side effect (re-pricing the claim, notifying the portal), guard that side effect on changed=True so a duplicate delivery does not re-fire it.
  • Fail closed on sync loss. If the stream and the poll both fail, the engine holds the last known state and continues to reject with 75 rather than assuming approval; persistent failure routes through Fallback Routing Logic Design.
  • PHI never rides the status event. The event carries case_id, member_token, state, and version only. The appeal narrative, denial rationale, 302-C2 Cardholder ID, and 310-CA Patient Name stay in the PHI-scoped review store per Security & Compliance Boundaries for Claims Data; a status pipeline that logs them is a reportable breach.

← Back to Prior Authorization Routing Automation