Diff a renewal policy against the expiring one

Renewal quotes come back and someone has to answer one question: what changed. Not "did the premium move" — the whole picture. Limits, deductibles, endorsement schedule, named insureds, locations, exclusions. On a mid-size commercial account that is a 40-page declarations and forms list against last year's 38-page one, and the account manager reading it is doing it at 4pm between certificate requests.

Most agencies handle this by eyeball on the premium and the general liability limits, and take the rest on trust. That is where the E&O sits: a $1M/$2M that quietly became $1M/$1M, a blanket additional insured endorsement swapped for a scheduled one, a wind/hail deductible that went from flat $5,000 to 2% of TIV.

This tutorial builds the diff. Extract both declarations into the same shape, normalize the values so cosmetic differences do not fire, compare, and hand the account manager a short list of real changes with page citations. The agent does not decide whether a change is acceptable. It makes sure nobody has to find it.

The shape of the problem

You are not diffing text. Diffing two PDFs as strings gives you hundreds of hits: different print dates, reordered forms lists, "1,000,000" versus "$1,000,000", a carrier that moved the deductible table to page 6. Every one of those is noise, and noise is how a review tool gets abandoned in week three.

So the pipeline has three stages, and each one exists to kill a class of false positive:

  1. Extract both documents into a typed record.
  2. Normalize each value into a canonical form.
  3. Compare field by field, with tolerances you chose on purpose.

Stage 1: extract into a typed record

Use whatever extraction you already run for submissions. We use Textract Queries against the declarations pages, one query per field, because it returns a confidence score and a page reference per answer, and the citation is half the value here. (If you have not wired that up, see Extract ACORD fields with Textract Queries.)

Define the record before you write the extraction. This is the contract the rest of the pipeline reads:

from dataclasses import dataclass, field
from decimal import Decimal

@dataclass
class Value:
    raw: str
    page: int
    confidence: float

@dataclass
class PolicyRecord:
    policy_number: Value | None
    carrier: Value | None
    effective: Value | None
    expiration: Value | None
    named_insureds: list[Value] = field(default_factory=list)
    locations: list[Value] = field(default_factory=list)
    limits: dict[str, Value] = field(default_factory=dict)      # "gl_occurrence" -> Value
    deductibles: dict[str, Value] = field(default_factory=dict) # "property_wind_hail" -> Value
    forms: list[Value] = field(default_factory=list)            # "CG 20 10 04 13"
    premium: Value | None = None

Keep the key set small and per line of business. Ten to fifteen fields for a GL/property/auto package is enough to catch what actually hurts. A 60-field record produces a diff nobody reads.

Every extracted value keeps its page number. When the diff says the wind/hail deductible changed, the account manager should be one click from page 6 of the new declarations.

Stage 2: normalize before you compare

This is the stage that decides whether the tool is trusted. Comparison runs on canonical values, never on raw strings.

import re
from decimal import Decimal

MONEY = re.compile(r"[^0-9.]")

def money(raw: str) -> Decimal | None:
    """'$1,000,000.00' -> Decimal('1000000')"""
    cleaned = MONEY.sub("", raw or "")
    if not cleaned:
        return None
    return Decimal(cleaned).normalize()

def deductible(raw: str) -> tuple[str, Decimal] | None:
    """Flat dollars and percent-of-value are different animals."""
    raw = (raw or "").strip()
    pct = re.match(r"^([\d.]+)\s*%", raw)
    if pct:
        return ("PCT", Decimal(pct.group(1)))
    amt = money(raw)
    return ("FLAT", amt) if amt is not None else None

FORM = re.compile(r"^([A-Z]{2})\s*(\d{2})\s*(\d{2})\s*(\d{2}\s*\d{2})$")

def form_number(raw: str) -> str:
    """'CG2010 0413' and 'CG 20 10 04 13' are the same endorsement."""
    squashed = re.sub(r"\s+", "", (raw or "").upper())
    m = FORM.match(re.sub(r"\s+", " ", (raw or "").upper().strip()))
    if m:
        return " ".join([m.group(1), m.group(2), m.group(3), m.group(4).replace(" ", "")])
    return squashed

def name(raw: str) -> str:
    """Entity-suffix noise: 'Acme Mfg., Inc.' vs 'ACME MFG INC'."""
    s = (raw or "").upper()
    s = re.sub(r"[.,]", "", s)
    s = re.sub(r"\b(INCORPORATED|INC|LLC|L L C|CORP|CORPORATION|CO|LTD|LP|LLP)\b", "", s)
    return re.sub(r"\s+", " ", s).strip()

Two rules that save arguments later. First, ("FLAT", 5000) and ("PCT", 2) are not comparable, so a flat-to-percentage change is always reported as a change, never as a numeric delta — a 2% deductible on a $4M TIV building is $80,000 and the number 2 is smaller than 5,000. Second, normalize names and forms for matching, but always display the raw strings in the diff. The account manager needs to see what the document says, not what your regex made of it.

Stage 3: compare with deliberate tolerances

from dataclasses import dataclass

@dataclass
class Change:
    field: str
    severity: str        # "review" | "informational"
    old_raw: str | None
    new_raw: str | None
    old_page: int | None
    new_page: int | None
    note: str = ""

PREMIUM_TOLERANCE = Decimal("0.02")   # 2% — under this, informational only

def compare_limits(old: PolicyRecord, new: PolicyRecord) -> list[Change]:
    changes = []
    for key in sorted(set(old.limits) | set(new.limits)):
        o, n = old.limits.get(key), new.limits.get(key)
        ov, nv = money(o.raw) if o else None, money(n.raw) if n else None
        if ov == nv:
            continue
        if ov is not None and nv is not None and nv < ov:
            note = f"limit reduced by {ov - nv}"
        elif ov is not None and nv is None:
            note = "limit not found on renewal declarations"
        else:
            note = "limit changed"
        changes.append(Change(
            field=f"limit.{key}", severity="review",
            old_raw=o.raw if o else None, new_raw=n.raw if n else None,
            old_page=o.page if o else None, new_page=n.page if n else None,
            note=note,
        ))
    return changes

def compare_forms(old: PolicyRecord, new: PolicyRecord) -> list[Change]:
    o = {form_number(v.raw): v for v in old.forms}
    n = {form_number(v.raw): v for v in new.forms}
    changes = []
    for gone in sorted(set(o) - set(n)):
        changes.append(Change("forms", "review", o[gone].raw, None,
                              o[gone].page, None, "endorsement not on renewal"))
    for added in sorted(set(n) - set(o)):
        changes.append(Change("forms", "review", None, n[added].raw,
                              None, n[added].page, "endorsement added at renewal"))
    return changes

Dropped endorsements are the highest-value output of the whole exercise, and they are the ones an eyeball read misses, because the absence of a line is invisible. Additional insured, waiver of subrogation, primary and non-contributory, blanket versus scheduled: a dropped or narrowed form is a promise the insured made in a contract that the policy no longer keeps.

Premium is the opposite. It changes every year and everybody already looks at it, so it is informational under your tolerance and a review item above it.

Confidence handling matters too. If either side of a field came back under your extraction threshold, do not report "no change" — report unverified and cite both pages. Silence from a low-confidence read is the one failure mode that makes this tool dangerous: it teaches the reviewer that a quiet field is a clean field.

Put the output where the work happens

Write the diff into the AMS as an activity on the policy, not into a separate dashboard. Applied Epic activity, HawkSoft log note, EZLynx task — whatever the agency already opens. The body is short: the review changes with old value, new value, and page citations, then a line pointing at the attached full diff.

RENEWAL DIFF — Acme Mfg / CPP 4471102 / eff 2026-11-01
3 items to review, 6 informational.

REVIEW
  GL each occurrence   $1,000,000 (p.2)  ->  $1,000,000 (p.2)   aggregate 2M -> 1M
  Property wind/hail   $5,000 flat (p.6) ->  2% of TIV (p.6)    basis changed
  Forms                CG 20 10 04 13 (p.11) -> not present     endorsement not on renewal

INFORMATIONAL
  Premium              $48,210 -> $49,180 (+2.0%)
  ... 5 more

Use the idempotency rules from writing agent output back to the AMS without duplicates. A renewal often gets re-quoted twice; you want the activity updated, not three activities on the same policy.

What this does not do

It does not tell you whether a change is acceptable. Narrower terms can be the right trade for a premium the insured will actually pay, and that call belongs to the producer and the account manager with the insured's contracts in front of them. It does not read the insured's lease or subcontract agreement to check whether a dropped additional insured endorsement breaches an obligation. It does not compare policy wording inside the forms — it compares form numbers and editions, which is a proxy, and a form edition change from 04 13 to 12 19 is flagged as a change precisely because we are not reading the wording for you.

And it does not send anything. The diff is a draft activity for a licensed person to work.

Measure it before you trust it

Run it silently against 40 renewals that already closed and score it the way you would any other agent: on each one, did a reviewer find a material change the diff missed. Misses are the number that matters here; a false positive costs 20 seconds, a missed dropped endorsement costs a claim denial. Our method for that is in scoring a silent run before go-live.

We build this as part of Renewal Radar engagements, in the agency's own AWS account, on Applied Epic, HawkSoft, and EZLynx. If you are running renewal reviews by eyeball and want a senior engineer on it, contact us.