Validating Deductible Thresholds Automatically in a Claims Pipeline

This page is a focused implementation guide under Coverage Validation Rules: it shows how to turn a resolved deductible into a deterministic, replayable “does this loss clear the deductible?” verdict that the rest of the gate can trust.

The naive deductible check — if loss_amount > policy.deductible: — fails the moment a real policy book hits the pipeline. Contemporary contracts carry tiered deductibles, percentage-of-dwelling deductibles that resolve to a dollar figure only after you read the Coverage A limit, peril-specific deductibles (wind/hail, named-storm, earthquake) that supersede the all-other-perils figure, and catastrophe deductibles that activate only after an official event declaration. On top of that, a single FNOL can match several of these at once, and the order in which they apply is not arbitrary — it is dictated by the policy form and, in some jurisdictions, by statute.

When the resolution order is left implicit in nested if branches, three concrete defects appear. Boundary cases (loss == deductible) flip non-deterministically because some code paths use > and others >=. Percentage deductibles silently truncate to the nearest cent under binary floating point, producing off-by-a-penny verdicts that an examiner can reproduce and dispute. And when the storm declaration that triggers the catastrophe deductible arrives after the claim was first scored, the original verdict is never re-evaluated. This page resolves all three: a deterministic resolution order modeled as a directed acyclic graph, exact Decimal arithmetic, and a re-evaluation hook keyed to event declarations.

Deductible resolution: ranked candidates, one cents-exact threshold, a parameterized boundary verdict A first-notice-of-loss enters a resolver that evaluates four deductible candidates in precedence order — catastrophe (rank 10), peril-specific (rank 20), percentage-of-Coverage-A (rank 30), and the flat all-other-perils fallback (rank 40). Each non-match falls through to the next rank. The first matching candidate produces one ResolvedDeductible with a cents-exact Decimal amount, source precedence, and an audit rationale, whose metadata trail is written to an append-only ledger. That threshold is compared against the loss using a boundary operator chosen per jurisdiction — strict greater-than or inclusive greater-or-equal — yielding CLEARS or BELOW_DEDUCTIBLE. If no candidate matches at any rank, the resolver raises NoApplicableDeductible and the claim is diverted, fail-closed, to MANUAL_REVIEW. FNOL peril · loss loss-date snapshot resolve_deductible() walk candidates in rank order · stop at first match Catastrophe · rank 10 trigger: event id ∈ active set % × Coverage A limit Peril-specific · rank 20 trigger: peril ∈ {wind, hail, quake} supersedes all-other-perils Percentage of Cov A · rank 30 amount = % × limit quantize → cents (ROUND_HALF_UP) All other perils · rank 40 flat fallback deductible always-present floor no match no match no match first match ResolvedDeductible amount: Decimal — cents-exact source_precedence · source_kind rationale (audit string) apply_deductible() loss ⟨OP⟩ threshold OP = BOUNDARY_OPS[boundary_mode] strict: > · inclusive: ≥ CLEARS loss meets threshold BELOW_DEDUCTIBLE loss under threshold MANUAL_REVIEW fail-closed divert no match at any rank raise NoApplicableDeductible snapshot incomplete · do not assume zero Append-only ledger · amount · precedence name · rationale string · active_event_ids · snapshot + rule id metadata trail

This pattern targets Python 3.10+ (for match statements and str | None unions) and uses only the standard library plus structlog for structured audit lines. Pin the dependency so coercion semantics cannot drift under you:

python -m venv .venv && source .venv/bin/activate
pip install "structlog==24.*"

Pipeline state required before this stage runs: the policy snapshot must already be resolved as of the loss date (the in-force endorsements, the Coverage A limit, and any scheduled event declarations), and the loss amount must be normalized to a Decimal upstream. Never accept a float loss amount into this function — coerce at the ingestion boundary, not here.

Step 1 — Model deductibles as typed, ordered candidates

Permalink to "Step 1 — Model deductibles as typed, ordered candidates"

Represent every deductible the policy can present as an immutable dataclass, and assign each a precedence rank. The engine evaluates candidates in rank order and stops at the first one whose trigger condition is satisfied — that is the DAG traversal expressed as data, not control flow.

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import IntEnum

import structlog

log = structlog.get_logger("deductible.resolver")


class Precedence(IntEnum):
    """Lower value = evaluated first. Catastrophe overrides peril overrides flat."""
    CATASTROPHE = 10
    PERIL_SPECIFIC = 20
    PERCENT_COVERAGE_A = 30
    ALL_OTHER_PERILS = 40


@dataclass(frozen=True, slots=True)
class DeductibleCandidate:
    precedence: Precedence
    kind: str                       # "flat" | "percent"
    value: Decimal                  # dollars for flat, fraction (0.02) for percent
    applies_to_perils: frozenset[str]
    requires_event_id: str | None   # set for catastrophe deductibles

Step 2 — Resolve the applicable threshold deterministically

Permalink to "Step 2 — Resolve the applicable threshold deterministically"

Resolution walks candidates in precedence order, applies each trigger guard against the claim context, and returns the first match together with the metadata trail that justifies it. Percentage deductibles are multiplied against the Coverage A limit and quantized to cents with an explicit rounding mode — never left to the default float behavior.

from dataclasses import dataclass

CENTS = Decimal("0.01")


@dataclass(frozen=True, slots=True)
class ResolvedDeductible:
    amount: Decimal
    source_precedence: Precedence
    source_kind: str
    rationale: str


def resolve_deductible(
    candidates: tuple[DeductibleCandidate, ...],
    *,
    peril: str,
    coverage_a_limit: Decimal,
    active_event_ids: frozenset[str],
) -> ResolvedDeductible:
    for cand in sorted(candidates, key=lambda c: c.precedence):
        if peril not in cand.applies_to_perils:
            continue
        if cand.requires_event_id and cand.requires_event_id not in active_event_ids:
            continue
        if cand.kind == "percent":
            amount = (coverage_a_limit * cand.value).quantize(CENTS, ROUND_HALF_UP)
            rationale = f"{cand.value:%} of Coverage A ({coverage_a_limit})"
        else:
            amount = cand.value.quantize(CENTS, ROUND_HALF_UP)
            rationale = "flat deductible"
        log.info("deductible.resolved", precedence=cand.precedence.name,
                 amount=str(amount), peril=peril)
        return ResolvedDeductible(amount, cand.precedence, cand.kind, rationale)
    raise NoApplicableDeductible(peril=peril)

Step 3 — Apply the threshold with a parameterized boundary operator

Permalink to "Step 3 — Apply the threshold with a parameterized boundary operator"

Whether a loss that exactly equals the deductible clears it is a jurisdictional rule, not a coding preference. Pass the operator in as configuration so the comparison is auditable and the boundary case is never implicit.

import operator
from typing import Callable

Verdict = str  # "CLEARS" | "BELOW_DEDUCTIBLE"

BOUNDARY_OPS: dict[str, Callable[[Decimal, Decimal], bool]] = {
    "strict": operator.gt,            # loss must exceed deductible
    "inclusive": operator.ge,         # loss meeting deductible clears
}


def apply_deductible(
    loss_amount: Decimal,
    resolved: ResolvedDeductible,
    *,
    boundary_mode: str,
) -> Verdict:
    if not isinstance(loss_amount, Decimal):
        raise TypeError("loss_amount must be Decimal; coerce at ingestion")
    clears = BOUNDARY_OPS[boundary_mode](loss_amount, resolved.amount)
    verdict: Verdict = "CLEARS" if clears else "BELOW_DEDUCTIBLE"
    log.info("deductible.applied", verdict=verdict, loss=str(loss_amount),
             threshold=str(resolved.amount), boundary_mode=boundary_mode)
    return verdict


class NoApplicableDeductible(Exception):
    def __init__(self, *, peril: str) -> None:
        super().__init__(f"no deductible candidate matched peril={peril}")
        self.peril = peril

A NoApplicableDeductible is never swallowed: it is a fail-closed signal that the policy snapshot is incomplete, and the surrounding gate must divert the claim to manual review rather than assume zero.

Confirm correctness with property-style assertions over the three defect classes named above. The boundary, the percentage rounding, and the catastrophe-trigger gating each get an explicit test.

from decimal import Decimal

def _percent(val: str, perils: frozenset[str]) -> DeductibleCandidate:
    return DeductibleCandidate(Precedence.PERCENT_COVERAGE_A, "percent",
                               Decimal(val), perils, None)

def test_percent_quantizes_exactly() -> None:
    cand = (_percent("0.02", frozenset({"wind"})),)
    r = resolve_deductible(cand, peril="wind",
                           coverage_a_limit=Decimal("327500"),
                           active_event_ids=frozenset())
    assert r.amount == Decimal("6550.00")          # not 6549.999...

def test_boundary_strict_vs_inclusive() -> None:
    r = ResolvedDeductible(Decimal("1000.00"), Precedence.ALL_OTHER_PERILS,
                           "flat", "flat deductible")
    assert apply_deductible(Decimal("1000.00"), r, boundary_mode="strict") == "BELOW_DEDUCTIBLE"
    assert apply_deductible(Decimal("1000.00"), r, boundary_mode="inclusive") == "CLEARS"

def test_catastrophe_inactive_falls_through() -> None:
    cat = DeductibleCandidate(Precedence.CATASTROPHE, "percent", Decimal("0.05"),
                              frozenset({"wind"}), requires_event_id="NAMED-STORM-2026-07")
    flat = DeductibleCandidate(Precedence.ALL_OTHER_PERILS, "flat", Decimal("2500"),
                               frozenset({"wind"}), None)
    r = resolve_deductible((cat, flat), peril="wind",
                           coverage_a_limit=Decimal("400000"),
                           active_event_ids=frozenset())   # storm not yet declared
    assert r.source_precedence is Precedence.ALL_OTHER_PERILS

Run the suite under python -m pytest -q. The decisive signal is that every assertion is exact: Decimal equality has no tolerance band, which is precisely what makes the verdict replayable.

Because resolution is a pure function over the snapshot, the loss amount, and the boundary mode, every verdict is reproducible from the audit record alone. Persist the ResolvedDeductible (amount, precedence name, and rationale string) alongside the rule version and the loss-date snapshot id before the routing action is published — written to the same append-only ledger the Coverage Validation Rules gate already maintains. When a regulator asks why a claim was settled below or above its deductible, the rationale string (“5% of Coverage A (400000)”) and the captured active_event_ids reconstruct the exact decision, satisfying market-conduct and NAIC unfair-claims-settlement traceability requirements without replaying the live policy service.

  • Penny drift on percentage deductibles. Symptom: verdicts flip for losses within a cent of the threshold. Cause: a float leaked into the multiplication. Fix: assert isinstance(coverage_a_limit, Decimal) at the resolver boundary and quantize with an explicit ROUND_HALF_UP.
  • Catastrophe deductible never fires. Symptom: storm claims clear an all-other-perils figure they should not. Cause: the event declaration landed after scoring and nothing re-ran resolution. Fix: subscribe the gate to event-declaration events and re-evaluate any claim whose loss date falls inside the declared window, then reconcile reserves downstream.
  • NoApplicableDeductible floods manual review. Symptom: a spike of fail-closed diversions. Cause: the snapshot is missing endorsement perils, so no candidate matches. Fix: treat it as a data-completeness alert on ingestion, not a resolver bug — do not add a permissive zero-dollar default.
  • Boundary verdict disagrees with the adjuster. Symptom: a loss == deductible claim is disputed. Cause: boundary_mode defaulted instead of being set per jurisdiction. Fix: source the mode from the same state-rule configuration the gate already loads; never hardcode > in the comparison.
  • Concurrent claims reset the annual accumulator. Symptom: a second claim on the same policy ignores an eroding aggregate deductible. Fix: read the accumulator under an optimistic-lock version on the snapshot so two FNOLs in the same window cannot both see the pristine balance.