Handling Sublimit Stacking in Coverage Checks
This page is a focused implementation guide under Coverage Validation Rules: it shows how to compute a defensible payable amount when a single loss is capped by several nested and overlapping sublimits at once, so the figure a claim is settled at can never exceed the tightest binding constraint.
Problem Statement
Permalink to "Problem Statement"A homeowners or commercial property loss rarely meets a single flat limit. The same water-damage claim can be simultaneously capped by a per-item sublimit (jewelry at $2,500 a piece), a per-occurrence sublimit for the peril (mold remediation at $10,000), an ordinance-or-law sublimit expressed as a percentage of Coverage A, and an annual aggregate that has already been eroded by two earlier claims this policy term. These limits nest: the per-item cap lives inside the per-occurrence cap, which lives inside the aggregate. The payable figure is not the smallest limit in isolation — it is the smallest remaining headroom once every applicable constraint is considered together.
When this is implemented as a chain of min() calls hand-written per product, three concrete defects surface. First, engineers forget that an aggregate erodes across claims, so a policy that should have 10,000 per-occurrence figure a fourth time. Second, percentage-of-dwelling sublimits truncate under binary floating point, producing a payable that is off by a penny from what the reinsurance treaty expects. Third, the order in which sublimits bind becomes invisible, so when an auditor asks “which limit capped this payout?” nobody can answer. This guide models each limit as a typed constraint in a precedence-ordered set, resolves the single binding constraint with exact Decimal arithmetic, and records the erosion of aggregates across claims. It complements validating deductible thresholds automatically, which runs earlier in the same gate to decide whether a loss clears its deductible at all.
Prerequisites
Permalink to "Prerequisites"This pattern targets Python 3.10+ and uses only the standard library plus structlog for structured audit lines. Pin the dependency so serialization semantics cannot drift:
python -m venv .venv && source .venv/bin/activate
pip install "structlog==24.*"
Pipeline state required before this stage runs: the policy snapshot must be resolved as of the loss date (in-force sublimit endorsements, the Coverage A limit, and any per-item schedules), the loss amount must already be a Decimal, and the annual aggregate accumulator for the policy term must be readable under an optimistic-lock version so two concurrent claims cannot both see the pristine balance. This guide assumes the deductible has already been cleared upstream; sublimit stacking decides how much of the post-deductible loss is payable.
Step-by-Step Implementation
Permalink to "Step-by-Step Implementation"Step 1 — Model each limit as a typed, precedence-ordered constraint
Permalink to "Step 1 — Model each limit as a typed, precedence-ordered constraint"Represent every cap the policy can impose as an immutable constraint carrying a precedence rank and enough context to compute its own remaining headroom. Nesting is expressed as data — a rank order — not as hand-written min() nesting that hides which limit bound the payout.
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("coverage.sublimits")
CENTS = Decimal("0.01")
class LimitScope(IntEnum):
"""Lower value binds first when reported headroom ties."""
PER_ITEM = 10
PER_OCCURRENCE = 20
ORDINANCE_OR_LAW = 30
ANNUAL_AGGREGATE = 40
@dataclass(frozen=True, slots=True)
class SublimitConstraint:
scope: LimitScope
kind: str # "flat" | "percent"
value: Decimal # dollars for flat; fraction (0.10) for percent
label: str # human/audit label, e.g. "mold per-occurrence"
eroded_to_date: Decimal = Decimal("0.00") # only meaningful for aggregates
def remaining(self, *, coverage_a_limit: Decimal) -> Decimal:
if self.kind == "percent":
gross = (coverage_a_limit * self.value).quantize(CENTS, ROUND_HALF_UP)
else:
gross = self.value.quantize(CENTS, ROUND_HALF_UP)
return (gross - self.eroded_to_date).quantize(CENTS, ROUND_HALF_UP)
Step 2 — Resolve the single binding constraint with exact Decimal math
Permalink to "Step 2 — Resolve the single binding constraint with exact Decimal math"The payable amount is the smaller of the loss and the tightest remaining headroom across all applicable constraints. Compute each constraint’s headroom, take the minimum, and record which constraint produced it. Negative headroom (an already-exhausted aggregate) is clamped to zero, never allowed to go below.
@dataclass(frozen=True, slots=True)
class PayableDecision:
payable: Decimal
binding_scope: LimitScope
binding_label: str
fully_payable: bool
rationale: str
def resolve_binding_limit(
loss_amount: Decimal,
constraints: tuple[SublimitConstraint, ...],
*,
coverage_a_limit: Decimal,
) -> PayableDecision:
if not isinstance(loss_amount, Decimal):
raise TypeError("loss_amount must be Decimal; coerce at ingestion")
if not constraints:
raise NoApplicableSublimit("no sublimit constraint supplied")
ranked = sorted(constraints, key=lambda c: c.scope)
headrooms = [
(c, max(Decimal("0.00"), c.remaining(coverage_a_limit=coverage_a_limit)))
for c in ranked
]
binding, binding_headroom = min(headrooms, key=lambda pair: (pair[1], pair[0].scope))
payable = min(loss_amount, binding_headroom).quantize(CENTS, ROUND_HALF_UP)
fully = payable == loss_amount
rationale = (
f"{binding.label} bound payout at {binding_headroom}"
if not fully else f"loss fully within {binding.label}"
)
log.info("sublimit.resolved", payable=str(payable),
binding=binding.label, scope=binding.scope.name, fully_payable=fully)
return PayableDecision(payable, binding.scope, binding.label, fully, rationale)
class NoApplicableSublimit(Exception):
"""Fail-closed: the snapshot presented no constraints to evaluate."""
Step 3 — Erode the annual aggregate across claims
Permalink to "Step 3 — Erode the annual aggregate across claims"An aggregate is stateful: each capped payout permanently reduces the headroom every later claim in the term sees. Return the new erosion figure so the caller can persist it under the same optimistic-lock version it read, closing the read-modify-write window.
def erode_aggregate(
aggregate: SublimitConstraint,
decision: PayableDecision,
*,
coverage_a_limit: Decimal,
) -> SublimitConstraint:
if aggregate.scope is not LimitScope.ANNUAL_AGGREGATE:
raise ValueError("only aggregates erode across claims")
new_eroded = (aggregate.eroded_to_date + decision.payable).quantize(
CENTS, ROUND_HALF_UP
)
gross = aggregate.value.quantize(CENTS, ROUND_HALF_UP)
if new_eroded > gross:
raise AggregateOverdrawn(
f"aggregate {aggregate.label} overdrawn: {new_eroded} > {gross}"
)
log.info("sublimit.aggregate_eroded", label=aggregate.label,
eroded_to_date=str(new_eroded), remaining=str(gross - new_eroded))
from dataclasses import replace
return replace(aggregate, eroded_to_date=new_eroded)
class AggregateOverdrawn(Exception):
"""Two concurrent claims both drew on the same pristine aggregate balance."""
Verification & Testing
Permalink to "Verification & Testing"Cover the three defect classes named in the problem statement — an eroded aggregate binding over a nominally larger per-occurrence cap, exact percentage quantization, and the boundary where the loss exactly equals the binding headroom. Every assertion is exact because Decimal equality has no tolerance band.
from decimal import Decimal
def _agg(limit: str, eroded: str) -> SublimitConstraint:
return SublimitConstraint(LimitScope.ANNUAL_AGGREGATE, "flat",
Decimal(limit), "annual aggregate",
eroded_to_date=Decimal(eroded))
def test_eroded_aggregate_binds_over_larger_occurrence_cap() -> None:
occ = SublimitConstraint(LimitScope.PER_OCCURRENCE, "flat",
Decimal("10000"), "mold per-occurrence")
agg = _agg("12000", "9000") # only 3000 left in the term
d = resolve_binding_limit(Decimal("8000"), (occ, agg),
coverage_a_limit=Decimal("400000"))
assert d.payable == Decimal("3000.00")
assert d.binding_scope is LimitScope.ANNUAL_AGGREGATE
def test_percent_ordinance_quantizes_exactly() -> None:
ord_law = SublimitConstraint(LimitScope.ORDINANCE_OR_LAW, "percent",
Decimal("0.10"), "ordinance or law")
d = resolve_binding_limit(Decimal("50000"), (ord_law,),
coverage_a_limit=Decimal("327500"))
assert d.payable == Decimal("32750.00") # not 32749.999...
def test_loss_equal_to_headroom_is_fully_payable() -> None:
occ = SublimitConstraint(LimitScope.PER_OCCURRENCE, "flat",
Decimal("2500"), "jewelry per-item")
d = resolve_binding_limit(Decimal("2500.00"), (occ,),
coverage_a_limit=Decimal("400000"))
assert d.fully_payable is True
assert d.payable == Decimal("2500.00")
def test_overdrawn_aggregate_raises() -> None:
agg = _agg("5000", "4000")
d = resolve_binding_limit(Decimal("2000"), (agg,),
coverage_a_limit=Decimal("400000"))
import pytest
with pytest.raises(AggregateOverdrawn):
# payable is 1000, but simulate a stale read that already saw 4500
erode_aggregate(_agg("5000", "4500"), d,
coverage_a_limit=Decimal("400000"))
Run the suite under python -m pytest -q. The decisive signal is that the binding constraint is always named in the decision, so a failing assertion tells you not just that the payable was wrong but which cap the resolver believed was tightest.
Compliance & Audit Note
Permalink to "Compliance & Audit Note"Because resolve_binding_limit is a pure function over the loss, the constraint set, and the Coverage A limit, every payable figure is reproducible from the audit record alone. Persist the PayableDecision — the payable amount, the binding constraint label, and the aggregate balance after erosion — alongside the rule version and loss-date snapshot id, written to the same append-only ledger the Coverage Validation Rules gate already maintains. When a market-conduct examiner asks why a mold claim settled at 10,000 endorsement figure, the recorded binding_label (“annual aggregate”) and the erosion trail reconstruct the exact decision without replaying the live policy service, satisfying NAIC unfair-claims-settlement traceability. Recording which limit bound the payout is what separates a defensible sublimit decision from an unexplained number.
Troubleshooting Checklist
Permalink to "Troubleshooting Checklist"- Aggregate ignored on the second claim of the term. Symptom: two claims each pay the full per-occurrence cap even though the aggregate should have exhausted. Cause:
eroded_to_datewas read but never persisted after the first payout, or was read outside the optimistic-lock version. Fix: return the eroded constraint fromerode_aggregateand write it under the same snapshot version you read, treating anAggregateOverdrawnas a concurrency retry, not a data error. - Payable off by a penny. Symptom: the settled figure disagrees with the reinsurance bordereau by one cent. Cause: a percentage sublimit multiplied a
floatCoverage A limit. Fix: assertisinstance(coverage_a_limit, Decimal)at the boundary and quantize every intermediate with an explicitROUND_HALF_UP. - Wrong limit reported as binding. Symptom: the audit trail names a per-item cap when the aggregate was actually tighter. Cause: two constraints reported equal headroom and the tie broke arbitrarily. Fix: the
minkey already breaks ties byscoperank — never sort headrooms without the scope tiebreak. - Negative headroom flows into the payout. Symptom: an exhausted aggregate produces a negative payable. Cause:
remaining()returned a negative value that was not clamped. Fix: clamp withmax(Decimal("0.00"), …)as shown; a spent limit contributes zero headroom, never a credit. - NoApplicableSublimit floods manual review. Symptom: a spike of fail-closed diversions. Cause: the snapshot is missing sublimit endorsements, so the constraint tuple is empty. Fix: treat it as a data-completeness alert on ingestion; do not add a permissive unlimited default.
Related
Permalink to "Related"- Coverage Validation Rules — the parent eligibility gate this resolver plugs into
- Validating Deductible Thresholds Automatically — the earlier stage that decides whether a loss clears its deductible before sublimits apply
- Implementing Priority Queues for Catastrophic Claims — where aggregate erosion across a catastrophe event drives surge routing
- Building Append-Only Hash-Chained Audit Logs — the ledger design that makes a binding-constraint record tamper-evident
- Claims Triage & Routing Engines — the control plane this stage belongs to