Designing Fallback Routes for Missing Adjuster Data
This page is a focused implementation guide under Claims Lifecycle Architecture: it shows how to keep a claim moving deterministically through triage when the incoming payload arrives without a usable adjuster assignment, instead of stalling the pipeline or silently propagating a None.
Problem Statement
Permalink to "Problem Statement"In a production claims pipeline, a meaningful fraction of inbound events carry no adjuster identifier. Legacy policy administration exports omit the field entirely, partner FNOL APIs send an empty string, and a recently terminated adjuster’s ID still rides on backlogged events. The naive handling — let the missing value flow downstream and assign it later — produces three concrete defects.
First, a None adjuster propagated into adjudication opens a reserve against a claim that nobody owns, and the loss sits in an invisible backlog until an SLA timer fires days later. Second, when an exception is raised on the missing field and the payload is dropped, the loss event is lost entirely — a regulatory reportable in most jurisdictions. Third, when fallback assignment is bolted on as ad-hoc exception handling, the same claim replayed during an event-sourcing recovery lands on a different adjuster than it did originally, and no examiner can reconstruct which assignment was authoritative.
This page resolves all three. Detection happens at the ingestion boundary as a business branch rather than a schema crash, fallback resolution runs as a deterministic, jurisdiction-aware hierarchy that produces the same adjuster on replay, and every fallback decision emits a machine-readable reason code into the audit spine. The result is a first-class routing tier that degrades gracefully without compromising auditability or memory stability.
Prerequisites
Permalink to "Prerequisites"This pattern targets Python 3.10+ for the str | None union syntax and frozen-dataclass ergonomics, and pins Pydantic v2 so coercion and validation semantics cannot drift under a minor bump:
python -m venv .venv && source .venv/bin/activate
pip install "pydantic==2.*"
Pipeline state required before this stage runs: the claim must already be normalized into the canonical model emitted by the ingestion gate, the state_of_loss must be a validated two-letter code, and a versioned adjuster directory — keyed by license state and carrying a real-time workload counter — must be reachable. The directory should sit behind a memory-mapped cache or a Redis-backed lookup with a strict eviction policy, never a per-claim round trip to the system of record. Apply the same jurisdictional rules sourced by State Regulation Mapping so a fallback never assigns an adjuster who lacks authority in the loss state.
Step-by-Step Implementation
Permalink to "Step-by-Step Implementation"Step 1 — Detect the gap at the ingestion boundary without discarding the payload
Permalink to "Step 1 — Detect the gap at the ingestion boundary without discarding the payload"A missing adjuster is a business exception, not a corrupt payload, so the model must declare adjuster_id optional and flag the absence rather than raise. Corrupt-payload conditions (missing claim_id, an invalid state_of_loss) still raise through separate field validators; the two failure classes must never be conflated.
from __future__ import annotations
import logging
from pydantic import BaseModel, field_validator, model_validator
logger = logging.getLogger("claims.fallback")
_STATE_CODES = frozenset(
"AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN "
"MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA "
"WA WV WI WY DC".split()
)
class ClaimPayload(BaseModel):
claim_id: str
policy_number: str
state_of_loss: str
adjuster_id: str | None = None
@field_validator("state_of_loss")
@classmethod
def _valid_state(cls, v: str) -> str:
if v.upper() not in _STATE_CODES:
raise ValueError(f"unrecognized state_of_loss={v!r}")
return v.upper()
@property
def needs_fallback(self) -> bool:
return self.adjuster_id is None or not self.adjuster_id.strip()
@model_validator(mode="after")
def _flag_missing_adjuster(self) -> "ClaimPayload":
if self.needs_fallback:
logger.warning(
"claim_id=%s missing adjuster_id; routing to fallback dispatch",
self.claim_id,
)
return self
The model returns intact so the caller can test payload.needs_fallback and branch to the resolver. Route flagged records to a dedicated dead-letter queue for fallback evaluation so ingestion throughput is decoupled from resolution latency.
Step 2 — Resolve a fallback adjuster through a deterministic, jurisdiction-aware hierarchy
Permalink to "Step 2 — Resolve a fallback adjuster through a deterministic, jurisdiction-aware hierarchy"Resolution walks an ordered hierarchy of strategies and stops at the first that satisfies the active compliance constraints. The decisive design rule, shared with the rest of the Claims Lifecycle Architecture, is that the pick must be a pure function of the claim and the directory snapshot — so within each strategy, ties are broken by hashing the claim_id, never by wall-clock order or a live counter that changes between replays.
from dataclasses import dataclass
from enum import IntEnum
from hashlib import sha256
class Strategy(IntEnum):
"""Lower value = tried first."""
LICENSE_STATE = 10
REGIONAL_POOL = 20
WORKLOAD_BALANCED = 30
MANUAL_ESCALATION = 40
@dataclass(frozen=True, slots=True)
class Adjuster:
adjuster_id: str
license_states: frozenset[str]
region: str
open_claims: int
@dataclass(frozen=True, slots=True)
class FallbackDecision:
adjuster_id: str | None
strategy: Strategy
reason_code: str
def _deterministic_pick(claim_id: str, pool: tuple[Adjuster, ...]) -> Adjuster:
"""Stable selection: same claim + same pool always yields the same adjuster."""
ordered = sorted(pool, key=lambda a: a.adjuster_id)
digest = int(sha256(claim_id.encode()).hexdigest(), 16)
return ordered[digest % len(ordered)]
def resolve_fallback(
claim: ClaimPayload, directory: tuple[Adjuster, ...]
) -> FallbackDecision:
state = claim.state_of_loss
licensed = tuple(a for a in directory if state in a.license_states)
if not licensed:
return FallbackDecision(None, Strategy.MANUAL_ESCALATION, "ADJ_LICENSE_MISMATCH")
regional = tuple(a for a in licensed if a.region == state)
if regional:
pick = _deterministic_pick(claim.claim_id, regional)
return FallbackDecision(pick.adjuster_id, Strategy.REGIONAL_POOL, "ADJ_POOL_REGION")
# No in-region match: balance load across the licensed set, ties broken deterministically.
min_load = min(a.open_claims for a in licensed)
least_loaded = tuple(a for a in licensed if a.open_claims == min_load)
pick = _deterministic_pick(claim.claim_id, least_loaded)
return FallbackDecision(pick.adjuster_id, Strategy.WORKLOAD_BALANCED, "ADJ_WORKLOAD")
An empty licensed set fails closed to ADJ_LICENSE_MISMATCH with a None assignment, which the caller routes to a supervisory queue rather than guessing — never assign across a jurisdictional boundary just to clear the backlog. Once a primary adjuster exists, the richer ranking in the Adjuster Assignment Algorithms engine takes over; the fallback resolver only has to produce a safe, reproducible holding assignment.
Step 3 — Stream candidates under memory pressure and emit the audit record
Permalink to "Step 3 — Stream candidates under memory pressure and emit the audit record"Batch fallback evaluation over a catastrophe-sized backlog must not materialize every payload at once — that triggers garbage-collection pauses and out-of-memory termination in containerized workers. A generator yields one decision at a time, and each decision is paired with an immutable audit fragment before any assignment is published.
from collections.abc import Iterable, Iterator
from datetime import datetime, timezone
@dataclass(frozen=True, slots=True)
class AuditFragment:
claim_id: str
payload_hash: str
strategy: str
reason_code: str
assigned_adjuster: str | None
state_of_loss: str
decided_at: str
def stream_fallback_decisions(
claims: Iterable[ClaimPayload], directory: tuple[Adjuster, ...]
) -> Iterator[AuditFragment]:
for claim in claims:
if not claim.needs_fallback:
continue
decision = resolve_fallback(claim, directory)
payload_hash = sha256(
f"{claim.claim_id}|{claim.policy_number}|{claim.state_of_loss}".encode()
).hexdigest()
yield AuditFragment(
claim_id=claim.claim_id,
payload_hash=payload_hash,
strategy=decision.strategy.name,
reason_code=decision.reason_code,
assigned_adjuster=decision.adjuster_id,
state_of_loss=claim.state_of_loss,
decided_at=datetime.now(timezone.utc).isoformat(),
)
The slots=True on every dataclass suppresses the per-instance __dict__, and yielding fragments instead of building a list keeps the working set flat regardless of backlog size. The consumer writes each fragment to append-only storage before publishing the routing action, so the audit record can never lag the decision it justifies.
Verification & Testing
Permalink to "Verification & Testing"Confirm the two properties that matter — determinism on replay and fail-closed jurisdictional safety — with explicit assertions rather than smoke tests.
from decimal import Decimal # noqa: F401 (illustrative import parity)
def _adj(aid: str, states: set[str], region: str, load: int) -> Adjuster:
return Adjuster(aid, frozenset(states), region, load)
def test_pick_is_stable_across_replays() -> None:
pool = (_adj("A1", {"TX"}, "TX", 5), _adj("A2", {"TX"}, "TX", 5))
claim = ClaimPayload(claim_id="C-100", policy_number="P-1", state_of_loss="TX")
first = resolve_fallback(claim, pool)
second = resolve_fallback(claim, pool)
assert first == second # identical input -> identical adjuster
def test_unlicensed_state_fails_closed() -> None:
pool = (_adj("A1", {"CA"}, "CA", 0),)
claim = ClaimPayload(claim_id="C-200", policy_number="P-2", state_of_loss="TX")
decision = resolve_fallback(claim, pool)
assert decision.adjuster_id is None
assert decision.reason_code == "ADJ_LICENSE_MISMATCH"
def test_missing_adjuster_flag_not_raised() -> None:
claim = ClaimPayload(claim_id="C-300", policy_number="P-3",
state_of_loss="fl", adjuster_id=" ")
assert claim.needs_fallback is True # empty string is treated as missing
assert claim.state_of_loss == "FL" # field validator normalized the code
Run the suite with python -m pytest -q. The decisive signal is first == second: because both the strategy walk and the tie-break hash are pure, the assertion holds with no tolerance band, which is exactly what makes a fallback assignment replayable during an event-sourcing recovery.
Compliance & Audit Note
Permalink to "Compliance & Audit Note"Every fallback decision carries a machine-readable reason code (ADJ_LICENSE_MISMATCH, ADJ_POOL_REGION, ADJ_WORKLOAD, ADJ_ESCALATE_MANUAL) and a payload hash persisted to the same append-only ledger the Claims Lifecycle Architecture audit spine already maintains. Because resolution is a pure function over the claim and the directory snapshot, an examiner can replay any historical assignment from the audit record alone — the captured state_of_loss, reason code, and hash reconstruct the exact branch without querying the live directory. Crucially, the fail-closed ADJ_LICENSE_MISMATCH path proves the system never auto-assigned a claim to an unlicensed adjuster, satisfying the jurisdictional-authority requirement that NAIC unfair-claims-settlement examinations probe. Synchronization to the audit store must be idempotent: deduplicate on the payload hash so a replayed batch cannot record two conflicting assignments, and restrict fallback metadata to downstream services that need adjudication context per Data Boundary Enforcement.
Troubleshooting Checklist
Permalink to "Troubleshooting Checklist"- Empty-string adjuster slips past detection. Symptom: claims with
adjuster_id=""reach adjudication unassigned. Cause: the check testedis Noneonly. Fix: use theneeds_fallbackproperty, which also rejects whitespace-only strings via.strip(). - Same claim lands on different adjusters across replays. Symptom: an event-sourcing recovery reassigns claims and reserves diverge. Cause: a tie-break used the live
open_claimscounter or queue order. Fix: break ties with_deterministic_pick(a hash ofclaim_idover the sorted pool), never a mutable runtime value. ADJ_LICENSE_MISMATCHfloods the manual queue. Symptom: a spike of escalations after onboarding a new state. Cause: the directory snapshot lacks adjusters licensed in that state. Fix: treat it as a directory-completeness alert at ingestion, not a resolver bug — do not relax the license guard to a permissive default.- OOM kill during a catastrophe backlog. Symptom: the fallback worker is terminated under a surge. Cause: the batch materialized every payload into a list. Fix: consume through
stream_fallback_decisionsand keepslots=Trueon the dataclasses so the working set stays flat. - Duplicate assignments recorded for one claim. Symptom: two adjusters appear in the audit log for the same loss. Cause: an at-least-once delivery replayed the batch and both runs wrote. Fix: deduplicate the audit write on
payload_hashso the second evaluation is collapsed to the canonical record.
Related
Permalink to "Related"- Routing High-Severity Claims to Senior Adjusters — what takes over once a primary adjuster exists
- Handling Multi-State Compliance in Claims Routing — where the jurisdictional license guard is sourced
- Validating Deductible Thresholds Automatically in a Claims Pipeline — the same deterministic, replayable verdict pattern applied to coverage
- Adjuster Assignment Algorithms — the primary assignment engine this fallback tier protects
- Claims Lifecycle Architecture — the parent component this routing tier belongs to