• contact@verticalserve.com
Home / Engineering / Post 106
Engineering Blog · Post #106

BOR Conflict Detection at Intake: Three Conflict Types, One State Machine

From "the BOR letter lifecycle table works fine for tracking the broker-of-record change once it starts, but a new submission can land in queue under a different broker than the active policy and nobody flags it until the UW is mid-quote and notices the broker mismatch" to "three detection types running at submission intake, idempotent on re-run, auto-hold on high severity, separate BORConflict state machine that doesn't pollute the existing Broker Of Record lifecycle, and a finalization step that promotes a resolved conflict into a real BOR record + queues PAS dispatch" — through three new tables, one detection rule table with cascading defaults, a finalize step that spans two systems, and a per-submission insured-notification artifact decoupled from the BOR letter row.


The Problem

Broker Of Record already exists. It tracks a BOR letter from incumbent contact through new-broker contact through 5-day grace period through resolution. It works. It's been working since Phase 1.

The hole isn't in the lifecycle. It's at intake.

A new submission lands. The submission carries broker_id=B-422 (Acme Brokerage). The insured on this submission, when looked up, has an active Policy issued under broker_id=B-118 (Apex Brokerage). Nobody told the workstation a BOR change is happening. Acme Brokerage might be:

  • Trying to win the account by submitting (legitimate; would normally come with a BOR letter from the insured).
  • A new producer at Apex who hasn't updated their book yet (data error).
  • A producer at a third party who got a copy of the renewal application by mistake (rare, real).
  • An adversarial broker raiding a competitor's book (BOR contest territory).

Today, the submission flows through normal intake routing. The UW assigned to it sees Acme as the broker, never realizes Apex is the incumbent, and starts working a quote. Three days later, when Apex calls in to ask why their renewal hasn't been touched, the workstation discovers the conflict. Now there's a quote in flight under the wrong broker, the incumbent is angry, and the UW has to undo work.

Two naïve framings:

  • Block all submissions where the broker doesn't match the prior policy. Most of the time that's not a conflict — broker reassignment is normal, intra-firm producer changes happen daily, sometimes a different broker just gets the renewal RFP. Blocking always = false-positive flood = nobody trusts the flag.
  • Add a column to Broker Of Record for "detected at intake." Now the BOR record's lifecycle has detection states blended in, the existing letter-tracking flow has to handle the case where there's no letter yet, and the UI for the BOR queue has to visually distinguish "letter received" from "conflict detected, no letter yet" — a bunch of state-machine bleed.

The right framing: a conflict is a separate entity from a BOR record. A conflict is the flag state at intake. Some conflicts resolve into a BOR change (incumbent loses, new broker wins, letter goes through PAS). Some end with the challenger withdrawing — no letter, no record, just a closed conflict. Some end with an "incumbent retains" decision — same outcome, no BOR record needed. Conflict ≠ BOR record. Two tables, two state machines, one well-defined seam between them.

The InsightUW Approach

graph TD subgraph Intake["Submission intake"] SUB["Submission Queue<br/>(new submission)"] end subgraph Detect["detect at intake"] D1["active policy diff broker<br/>(Policy join)"] D2["recent submission diff broker<br/>(Submission Queue join)"] D3["existing open bor<br/>(Broker Of Record join)"] Rule["BORDetection Rule<br/>(Builtin ← default ← LOB)"] end subgraph Conflict["BORConflict (state machine)"] S1["detected"] S2["broker notified"] S3["response pending"] S4a["resolved in favor of incumbent"] S4b["resolved in favor of challenger"] S4c["resolved split"] S4d["withdrawn"] S4e["manual override"] end subgraph Comm["BORConflict Response"] Resp["append-only<br/>broker / role / response type / text<br/>+ source message guid"] end subgraph Hold["Auto-hold"] Auto["BORConflict.auto held=true<br/>(no separate Held Submission)"] end subgraph Finalize["finalize resolution"] Spawn["spawn bor record<br/>(Broker Of Record)"] Hist["write history<br/>(BORHistory per policy)"] PAS["queue pas updates<br/>(Pas Message 'bor update')"] end SUB --> D1 SUB --> D2 SUB --> D3 Rule --> D1 Rule --> D2 Rule --> D3 D1 --> S1 D2 --> S1 D3 --> S1 S1 -->|severity ≥ floor| Auto S1 --> S2 S2 --> Resp S2 --> S3 S3 --> Resp S3 --> S4a S3 --> S4b S3 --> S4c S3 --> S4d S3 --> S4e S4b --> Spawn S4c --> Spawn Spawn --> Hist Spawn --> PAS

Conflict lives in its own table. It carries the auto held marker (no separate held-submission table — the submission is held because the conflict is open). Resolution either spawns a BOR record (challenger / split) or doesn't (incumbent / withdrawn / manual_override).

Three detection types

Idempotent. Re-running on the same submission produces zero duplicate rows. Each candidate row is one detection type × one incumbent broker pair.

The detection rule — cascading defaults

Resolution: BUILTIN_RULE ← default ← LOB-specific. A construction-LOB carrier might shorten active policy lookback days to 270 (renewals are predictable); a cyber LOB might raise severity recent submission to high (BOR shenanigans cluster).

The state machine

Five terminal states, three of which spawn nothing downstream (incumbent / withdrawn / manual_override) and two of which trigger the finalize chain (challenger / split).

notify brokers — best-effort send

Email failure doesn't block the workflow. The state advances to broker notified because the intent to notify happened; the UW gets a "Resend email" button on the conflict-detail page when the email outbox shows the send failed.

Responses — append-only log

Every response is one row. The conflict-detail page renders the response thread chronologically, color-coded by response type (accept=green, deny=red, withdraw=gray, request_info=blue, conditional=amber). The sweeper for overdue responses walks broker_notified | response_pending conflicts; same-day-deduped via existing Notification.

finalize resolution — the seam

Three things happen for the spawn path:

  1. spawn bor record writes a new Broker Of Record row with bor_status='active', 5-day grace period, conflict guid FK back to the conflict. The existing BOR letter lifecycle picks it up unchanged.
  2. write history adds one BORHistory row per affected policy with change_reason='bor_conflict_resolution'.
  3. queue pas updates writes Pas Message rows (message_type='bor_update', queued state). The PAS adapter framework's reconciler (cap #7, blog #93) picks them up; ack flips pas_update_status: pending → dispatched → acked | failed.

For split decisions, per policy decisions carries {policy_guid: 'incumbent' | 'challenger'} and one PAS message is queued per policy with the decision-specific old/new broker pair. Failed PAS dispatches surface a bor pas dispatch failed notification + retry button; retry pas dispatch re-queues without re-running the resolution.

The insured BOR notification artifact (Cap A)

A submission can carry an insured's BOR notice before a BOR letter row exists — mid-conflict, pre-letter. Decoupled table:

save auto-links to any open conflict on the submission and supersedes prior received|reviewed rows by default. link to bor record is called from finalize_resolution to associate the artifact with the spawned Broker Of Record. backfill from bor records is idempotent — first deploy mirrors legacy Broker Of Record.insured_notification_* columns into the new table without losing any history.

The legacy /insured-notification/{bor_guid} route keeps working unchanged; the new table is mirror-written.

Worked Example: Sarah's Monday Morning Triage

8:55 AM. Sarah opens the inbox. The "BOR" tab shows three new notifications.

bor conflict detected · Acme Construction · severity HIGH · 18m ago.

She clicks. Lands on /uw/bor/conflicts/c-44a2.

Overview card:
- Submission: SUB-2026-EX-0188 (Acme Construction Excess Casualty renewal)
- Conflict type: active policy diff broker
- Incumbent: Apex Brokerage (B-118, John Apex)
- Challenger: Acme Brokerage (B-422, Mary Acme)
- Severity: HIGH
- Auto-held: YES — submission is held until conflict resolves
- Status: detected

Detection summary: "Active Excess Casualty policy POL-2025-EX-0019 expires 2026-07-01 under broker B-118."

Insured BOR notification panel (Gap A — embedded above the response thread):
- no artifact yet

She clicks "Notify brokers." Modal asks for an optional cover note. She types "Routing this to compliance per standard protocol; please respond within 5 business days." Submits. State advances to broker notified. Two emails fire (template-rendered) to John Apex and Mary Acme. The integration log (blog #108) captures both sends.

11:30 AM. Mary Acme replies via email — the email-platform inbound has classified the reply as a BOR conflict response and pre-attached it to the conflict. Sarah opens the conflict; the response thread shows:

  • Mary Acme · challenger · accept · "Attached BOR letter signed by Acme Construction's CFO. Effective 2026-07-01." · received via email · 11:14 AM

Attachment: bor_letter_acme_signed.pdf. The system auto-saved the attachment as a Submission Document and created a Insured BORNotification row linked to the conflict — declared_incumbent_broker=B-118, declared_new_broker=B-422, declared_effective_date=2026-07-01, document_guid set, status='received'.

The insured-bor-notification-panel above the response thread now shows the artifact with its source chip, declared-fields grid, and a link to the attached PDF. Sarah clicks "Mark reviewed."

2:45 PM. John Apex replies. Response thread:

  • John Apex · incumbent · withdraw · "We're amenable to the BOR change; no contest." · received via email · 2:42 PM

Sarah clicks "Finalize." Modal:
- Decision: resolved in favor of challenger
- Effective date: 2026-07-01
- Resolution notes: "Incumbent withdrew; challenger BOR letter on file."

Submit. The chain runs:
1. spawn bor record — Broker Of Record row written with bor_status='active', conflict_guid=c-44a2 backlink.
2. link to bor record — the Insured BORNotification row gets bor record guid populated.
3. write history — one BORHistory row for POL-2025-EX-0019.
4. queue pas updates — one Pas Message queued, message_type='bor_update', payload {policy_guid, old_broker_id=B-118, new_broker_id=B-422, effective_date=2026-07-01, conflict_guid, bor_record_guid}.
5. Auto-hold released — submission un-held, normal routing resumes.

The submission queue is no longer held. Sarah moves on. The PAS reconciler picks up the bor_update message at the next tick; PAS acks within 30 seconds; pas update status flips pending → dispatched → acked. The PAS write-back updates Policy.broker_id on POL-2025-EX-0019.

Tuesday morning, the data integration hub (blog #108) trace viewer for correlation_id=c-44a2 shows the full chronology — detection → notify-incumbent email → notify-challenger email → conflict-response inbound → finalize → bor_record write → history write → PAS dispatch → PAS ack → broker write-back. One conflict, eleven log rows, full audit.

When the broker doesn't respond

A different conflict, two days later — Beta Industries D&O renewal. Same detection, same notify, no response. The overdue sweeper runs at 24h past the response deadline (default response_overdue_days=5):

  • bor response overdue notification fires at the assigned UW + the conflict's owner.
  • reminder_cadence_days=2 configures the next reminder.
  • After three reminders with no response, the UW manually finalizes as manual override (or withdrawn if the challenger formally withdraws).

The auto-hold remains until manual finalize. The submission doesn't slip into normal routing while ambiguous.

What's Deferred (Phase 2)

  • Confidence scoring on detection. Today, severity is per-rule. A confidence score (e.g., "active_policy_diff_broker on a policy that expired 2 months ago" → lower confidence) is mechanical to add but needs calibration data.
  • Auto-resolution heuristics. Today, every conflict needs human finalization. A heuristic that auto-resolves "challenger" when an insured BOR letter is on file + incumbent has been silent for >response_overdue_days could be added; intentionally deferred until the UW team trusts the detection rate.
  • Cross-LOB conflict aggregation. A single insured changing brokers for all their LOBs at once produces N conflicts (one per submission). Phase 2 work: a "BOR migration" wrapper that ties the conflicts together and lets one finalize action close all of them.
  • PAS-side BOR conflict signal. Some PAS systems can emit a "BOR change requested" event when a broker submits via the PAS portal directly. Wiring that as a fourth detection type is a small addition; deferred until the PAS partners expose the event.
  • Broker-portal response capture. Today, responses come in via email + broker portal as distinct paths. Phase 2: a unified response-form on the broker portal that writes directly to BORConflict Response with the source_message_guid pre-populated.
  • OCR on uploaded BOR letters. Today, the insured-notification artifact stores the document URL + declared fields manually entered or extracted from the email. OCR + structured extraction would let the artifact auto-populate. Stub in place; integration deferred.

What This Means for Underwriters

  1. Conflict ≠ BOR record. Conflicts are an intake-time flag. Some resolve into BOR changes; some don't. Keeping them separate keeps the BOR letter lifecycle clean.
  2. Three detection types cover the high-value cases. Active policy under different broker, recent submission under different broker, existing open BOR record. Each is one query at intake.
  3. Idempotent at intake. Re-running detection on the same submission produces zero duplicate rows. The dedup key is (submission, conflict_type, incumbent_broker).
  4. Auto-hold on high severity. Severe conflicts pause routing automatically — no Held Submission table needed; the conflict's auto_held=true flag is the marker.
  5. Best-effort notifications never block state advancement. Email failure doesn't pin the conflict in detected; the state moves to broker notified because the intent to notify happened. UW can resend.
  6. Response thread is append-only. Every broker, insured, or third-party response is a row. Color-coded UI; chronological order; full audit lineage.
  7. Finalize spans two systems atomically. Challenger / split decisions write a BOR record + history row + PAS message in one transaction. Failure surfaces a retry button without re-running the resolution decision.
  8. Insured BOR notification artifact decoupled. A submission can carry an insured's BOR notice before a BOR letter row exists. The artifact links forward to a BOR record once finalize spawns one — and backfill mirrors legacy Broker Of Record.insured_notification_* columns into the new table on first deploy without losing history.
  9. Per-LOB rules. Construction GL has different lookback windows than D&O has different severity floors than Cyber. Cascading defaults (BUILTIN ← default ← LOB) handle the variance.
  10. Sweeper drives the overdue path. Brokers who don't respond trigger reminders at reminder cadence days; the conflict doesn't go silent.
  11. Audit is end-to-end. Detection → notification → response → finalize → BOR record spawn → PAS dispatch → ack. Every step lands a row. The data integration hub (blog #108) provides the cross-system trace.
  12. Existing BOR view is unchanged. /uw/bor still shows BOR records (post-resolution); the new /uw/bor/conflicts shows the pre-resolution flag state. Two views, one vocabulary, clean separation.

What's Next

Voice profile + auto-draft for follow-ups (blog #107) reuses some of the same machinery — best-effort send paths, append-only response logs, status notifications — but the workflow is initiated by time (cadence elapsed) rather than event (intake mismatch). Same patterns; different driver.


Want to see how InsightUW catches BOR conflicts at intake without polluting your BOR letter lifecycle? Request a demo.

See InsightUW run on your data

A 45-minute working session with a real broker email and your LOBs.

Request a demo