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.
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.
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: str2. 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.
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.
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 == 2test_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
versionhas 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_idgives per-case order only if every producer keys oncase_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
APPROVEDleaves 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=Trueso 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
75rather 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, andversiononly. The appeal narrative, denial rationale,302-C2Cardholder ID, and310-CAPatient 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.
Related
- Prior Authorization Routing Automation — the case state machine whose transitions this sync layer propagates across systems.
- PBM Portal Sync Architecture — the broader reconciliation model for keeping portal-visible state consistent with adjudication.
- Fallback Routing Logic Design — how the engine holds state and fails closed when the status stream and its poll backstop are both down.
- Automating PA Override Approvals — the sibling step whose applied overrides this layer must propagate back to the PA system and portals.
← Back to Prior Authorization Routing Automation