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.

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.

Detecting a missing adjuster and resolving a deterministic, jurisdiction-aware fallback A claim event flows into an ingestion validator that tests whether adjuster_id is present. Present identifiers bypass the fallback tier and go to the primary assignment engine. Missing or empty identifiers enter the deterministic fallback resolver, a pure function of the claim and the directory snapshot. The resolver walks four ordered strategies: a license-state filter (an empty licensed set fails closed with ADJ_LICENSE_MISMATCH), a regional pool match (ADJ_POOL_REGION), a workload-balanced pick that breaks ties by hashing claim_id (ADJ_WORKLOAD), and manual escalation (ADJ_ESCALATE_MANUAL). The first satisfying strategy wins. Regional and workload matches converge on a resolved holding assignment that is reproducible on replay; the license-mismatch and escalation paths fail closed to a supervisory manual review queue. Beneath every branch, an immutable AuditFragment carrying the payload hash, reason code, and state of loss is written to an append-only ledger before any routing action is published. Claim event FNOL · legacy export · backlog Ingestion validator adjuster_id present? (business branch) present · skip fallback Primary assignment richer ranking takes over missing / empty Deterministic fallback resolver · pure(claim, directory) 1 · License-state filter state_of_loss ∈ license_states empty set → ADJ_LICENSE_MISMATCH 2 · Regional pool region == state · hash tie-break match → ADJ_POOL_REGION 3 · Workload-balanced min(open_claims) · hash tie-break match → ADJ_WORKLOAD 4 · Manual escalation supervisory queue ADJ_ESCALATE_MANUAL licensed set ≠ ∅ no in-region match no candidate Resolved holding assignment reproducible on replay Manual review queue fail-closed · supervisory never crosses jurisdiction Append-only audit ledger AuditFragment: payload_hash + reason_code + state_of_loss — written before any routing action is published every branch is audited

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

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.

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.

  • Empty-string adjuster slips past detection. Symptom: claims with adjuster_id="" reach adjudication unassigned. Cause: the check tested is None only. Fix: use the needs_fallback property, 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_claims counter or queue order. Fix: break ties with _deterministic_pick (a hash of claim_id over the sorted pool), never a mutable runtime value.
  • ADJ_LICENSE_MISMATCH floods 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_decisions and keep slots=True on 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_hash so the second evaluation is collapsed to the canonical record.