DEV Community

Seyed Alireza Alhosseini
Seyed Alireza Alhosseini

Posted on

Building a Submission Triage Engine: Why We Stopped Replacing Legacy Insurance Systems

How layering an Ontology + Agentic AI over a 40-year-old policy admin system cut triage time from days to hours — without touching a single line of COBOL.*


The Wall Every Insurance Dev Hits

If you've worked anywhere near insurance engineering, you know the conversation. It starts with "We need to modernize the policy admin system" and ends with someone whispering "five-year project, nine-figure budget, three careers ended."

The policy admin platform at most carriers was written before the architect retired. It's surrounded by extract jobs, Excel sheets, and heroic actuaries who manually reconcile data every Monday morning.

I used to think the only way forward was a rip-and-replace. Then I read the BD Emerson analysis of Palantir's insurance deployments — specifically Swiss Re's independently measured ROI and AIG's Lloyd's syndicate build — and realized the leading teams stopped trying to replace the core entirely.

They put an operational layer over the estate instead.

And the numbers are hard to argue with: 170% ROI, 7.3-month payback, 70–80% reporting time reduction (Nucleus Research, independently measured).

So I built something based on that philosophy. Here's what I learned.


The Problem: Submission Triage Is a Bottleneck

In commercial insurance, a broker emails a PDF submission. It contains:

  • Named insured details
  • Exposure descriptions
  • Historical losses
  • Coverage requests

Then the clock starts ticking.

An underwriter has to:

  1. Read the PDF (or worse, a 20-slide PowerPoint)
  2. Cross-reference it against the carrier's appetite (what risks they actually want)
  3. Check treaty capacity
  4. Look for similar historical losses
  5. Decide: quote, decline, or refer

This takes days. Sometimes weeks. During peak season, submissions sit in an inbox queue until the underwriter has cognitive bandwidth.

The legacy system doesn't help — it only stores bound policies. Everything before binding lives in email, SharePoint, and Excel.


The Insight: Don't Replace. Layer.

The BD Emerson article makes a point that changed how I think about insurance architecture:

"Instead of replacing the system of record first, you unify above it: connect the legacy platforms, model the book once, run underwriting, portfolio, and reporting workflows on the layer, and let the eventual core replacement become a data migration into a model that already works."

This means:

  • The 1980s policy admin system stays
  • The 1990s claims database stays
  • The actuarial Excel models stay
  • But above them, you build a unified operational layer

Palantir calls this an Ontology — a live graph of your business with objects, relations, and governed actions. I built a lightweight version of this architecture for submission triage.


The Architecture: Three Layers

┌─────────────────────────────────────────────────────────────┐
│  LAYER 3: AGENTIC AI (AIP)                                  │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │ LLM Gateway │  │ Triage Agent│  │ Underwriting Copilot│  │
│  │ (GPT/Claude)│  │ (Auto-route)│  │ (Suggest & Draft)   │  │
│  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘  │
│         │                │                    │              │
│  LAYER 2: ONTOLOGY (The Live Model)                         │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │  Objects: Submission, Insured, Exposure, Treaty, Loss  │ │
│  │  Relations: submitted_by, covers, triggers, refers_to  │ │
│  │  Actions: APPROVE, REFER, DECLINE, REQUEST_INFO        │ │
│  └─────────────────────────────────────────────────────────┘ │
│         │                │                    │              │
│  LAYER 1: LEGACY CONNECTORS (Read-Only / Write-Back)        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐   │
│  │ Policy   │ │ Claims   │ │ Actuarial│ │ Broker Email │   │
│  │ Admin    │ │ System   │ │ Models   │ │ / PDF Inbox  │   │
│  │ (1980s)  │ │ (1990s)  │ │ (Excel)  │ │ (IMAP/API)   │   │
│  └──────────┘ └──────────┘ └──────────┘ └──────────────┘   │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Layer 1: Connectors (Don't Touch the Core)

I didn't write a single migration script for the legacy database. Instead, I built read-only connectors:

  • JDBC to the policy admin system for bound policy history
  • API to the claims system for loss runs
  • IMAP listener + OCR pipeline for broker emails and PDFs

The key rule: the legacy system remains the system of record for bound business. We only read from it. We never ask it to change.

Layer 2: The Ontology (The Source of Truth for Pre-Bind)

This is where the magic happens. Instead of storing submissions as files in a folder, we model them as objects in a graph:

# ontology/schema.py
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum

class Decision(Enum):
    AUTO_APPROVE = "auto_approve"
    REFER = "refer_to_underwriter"
    DECLINE = "decline"
    REQUEST_INFO = "request_more_info"

@dataclass
class Exposure:
    exposure_id: str
    class_code: str  # e.g., "11121 - Executive Offices"
    location: str
    limit_requested: float
    construction_year: Optional[int] = None

@dataclass
class Submission:
    submission_id: str
    broker_email: str
    insured_name: str
    exposures: List[Exposure]
    historical_losses: List[dict]
    attached_files: List[str]

    # Ontology relations (not just data)
    appetite_match_score: Optional[float] = None
    similar_bound_policies: List[str] = None
    treaty_capacity_remaining: Optional[float] = None
Enter fullscreen mode Exit fullscreen mode

The Ontology isn't just a database schema. It's a living model where:

  • A Submission relates to historical Loss objects
  • An Exposure triggers an AppetiteRule
  • A Decision creates an Action with full lineage

Layer 3: The Triage Agent (Agentic AI)

This is where the LLM comes in — but critically, it doesn't reason over raw PDFs. It reasons over the Ontology.

# agents/triage_agent.py
from typing import Literal
import json

class TriageAgent:
    def __init__(self, ontology_client, llm_client):
        self.ontology = ontology_client
        self.llm = llm_client

    def triage(self, submission_id: str) -> dict:
        # 1. Hydrate the submission from Ontology (not from PDF)
        submission = self.ontology.get_submission(submission_id)

        # 2. Enrich with governed context
        context = {
            "appetite_rules": self.ontology.get_active_appetite_rules(),
            "treaty_capacity": self.ontology.get_treaty_capacity(
                exposure_class=submission.exposures[0].class_code
            ),
            "similar_bound": self.ontology.find_similar_bound_policies(
                insured_name=submission.insured_name,
                class_code=submission.exposures[0].class_code
            ),
            "historical_losses": submission.historical_losses
        }

        # 3. LLM reasons over structured ontology, not raw text
        prompt = f"""
        You are an underwriting triage assistant.

        SUBMISSION: {json.dumps(submission, default=str)}
        PORTFOLIO CONTEXT: {json.dumps(context, default=str)}

        Rules:
        - If construction_year < 1990 AND location in California earthquake zone → REFER
        - If limit_requested > treaty_capacity_remaining → DECLINE (capacity)
        - If similar_bound_policies > 3 with clean loss history → AUTO_APPROVE
        - If no matching appetite rule → REQUEST_INFO

        Return JSON with:
        - decision: one of [AUTO_APPROVE, REFER, DECLINE, REQUEST_INFO]
        - confidence: 0.0 to 1.0
        - reasoning: step-by-step explanation
        - lineage: which ontology objects influenced this decision
        """

        response = self.llm.complete(prompt)
        decision = json.loads(response)

        # 4. Write decision back to Ontology (not legacy system)
        self.ontology.record_decision(
            submission_id=submission_id,
            decision=decision["decision"],
            confidence=decision["confidence"],
            reasoning=decision["reasoning"],
            lineage=decision["lineage"],
            actor="triage_agent_v2.1"
        )

        return decision
Enter fullscreen mode Exit fullscreen mode

Critical design choice: The agent inherits the same permissions as the human underwriter. If the underwriter can't see treaty data for Syndicate X, the agent can't either. This isn't bolted-on security — it's native to the Ontology layer.


The Governance Layer: Why This Isn't a Black Box

Insurance is regulated. Reinsurance partners audit you. A triage agent that says "trust me" is useless.

Every decision in our system writes to an immutable audit graph:

# governance/lineage.py
@dataclass
class DecisionLineage:
    decision_id: str
    submission_id: str
    timestamp: str
    actor: str  # "triage_agent_v2.1" or "human_underwriter_jane"

    # Every data point that influenced the decision
    evidence: List[EvidenceNode]

    # Human checkpoints
    human_approval_required: bool
    human_approved_by: Optional[str] = None

@dataclass
class EvidenceNode:
    source_type: Literal["ontology_object", "legacy_extract", "llm_reasoning"]
    object_id: str
    snapshot_at_decision_time: dict  # What the data looked like when decided
Enter fullscreen mode Exit fullscreen mode

When a regulator asks "Why was this submission declined?" we don't show them a model weight. We show them a traceable graph:

Submission #4472 → Declined
  ↳ Because: Exposure "Warehouse CA" → triggered AppetiteRule #EQ-1990
    ↳ Because: construction_year=1985 AND location=California
      ↳ Source: PDF page 3, extracted by OCR at 2026-08-05T14:23:11Z
      ↳ Validated by: human_underwriter_jane (override on 2026-08-05T15:00:00Z)
Enter fullscreen mode Exit fullscreen mode

This is what Palantir calls "decision lineage" — and it's the difference between a demo and a production system.


Real-World Validation: Why This Isn't Theoretical

I didn't invent this pattern. I adapted it from what's already working:

Carrier What They Did Result
Swiss Re Deployed Foundry for underwriting + portfolio analytics 170% ROI, 7.3-month payback, 30% underwriter time saved
AIG Built Lloyd's syndicate with Palantir Ontology + LLM Submission triage: days → hours; 4M+ industry data points for underwriting
GNP Seguros Expanded Palantir AIP across health, life, auto, damage Fraud detection before payment; real-time underwriting changes

The pattern is consistent: unify above legacy, model the business as an Ontology, let AI reason over governed objects, not raw files.


The MVP Roadmap: 90 Days to Production

If you're a developer looking to build this, here's how I'd scope it:

Month 1: Ingestion & Ontology v0.1

  • Week 1-2: Build the PDF/OCR ingestion pipeline (AWS Textract, Azure DI, or open-source Tesseract + layoutLM)
  • Week 3-4: Define your core objects: Submission, Exposure, Insured, AppetiteRule. Use a graph database (Neo4j) or Palantir Foundry if you have access.

Month 2: The Triage Agent

  • Week 5-6: Prompt engineer the LLM to reason over structured ontology context (not raw text). Use Claude 3.5 Sonnet or GPT-4.
  • Week 7-8: Build the Action Layer: AUTO_APPROVE routes to policy issuance API; REFER opens an underwriter workbench with full context pre-loaded.

Month 3: Governance & Feedback Loop

  • Week 9-10: Implement lineage tracking. Every decision must be traceable to source data.
  • Week 11-12: A/B test: route 20% of submissions through the old process, 20% through the new engine. Measure time-to-triage, conversion rate, and underwriter satisfaction.

The "Golden Layer": Confidence Scoring + Feedback

If you want to go from "good" to "exceptional," add these two mechanisms:

1. Confidence-Based Routing

def route_by_confidence(decision: dict) -> str:
    confidence = decision["confidence"]

    if confidence > 0.95:
        return "AUTO_APPROVE"  # No human touch
    elif confidence > 0.70:
        return "REFER_WITH_SUMMARY"  # Human validates, AI did the work
    else:
        return "SENIOR_UNDERWRITER"  # AI explains why it's uncertain
Enter fullscreen mode Exit fullscreen mode

2. The Feedback Loop

Every time a human underwriter overrides the AI:

  1. Capture the delta (what did AI say vs. what did human do?)
  2. Write it back to the Ontology as a CorrectionEvent
  3. Weekly fine-tuning run on the LLM using corrected examples
  4. Watch AUTO_APPROVE rate climb from 30% → 70% over 6 months

Why This Matters for Developers

As engineers, we love greenfield projects. We want to rewrite the monolith in Rust, containerize everything, and deploy on Kubernetes.

But in insurance — and in most regulated enterprises — the legacy system isn't going anywhere. The business can't tolerate a 5-year migration. The data is too messy, the integrations too deep, the risk too high.

The Palantir approach — and the one I've validated in my builds — teaches us a different skill:

The ability to build a live, intelligent layer over a system you don't control, and create value in weeks instead of years.

That's not just an architecture pattern. It's a career-defining capability.


Key Takeaways

  1. Don't replace legacy — unify above it. The policy admin system stays. Your Ontology becomes the pre-bind source of truth.
  2. LLMs must reason over Ontology, not PDFs. Raw document RAG is brittle. Structured object reasoning is governable.
  3. Governance is not optional decoration. Purpose-based access, decision lineage, and human checkpoints must be native to your architecture.
  4. The Swiss Re math is real. 170% ROI in 7.3 months isn't marketing — it's what happens when underwriters stop reconciling extracts and start making decisions.

Seyed Alireza Alhosseini Almodarresieh

Top comments (0)