The Multi-Adapter Pattern: Why InsightUW Owns the Contract Even When InsightPulse Has the Data
From "we're going to call Pulse for board members and a different vendor for executives and a third one for news and a fourth one for ratings, and every workstation page that wants this data ends up with a different code path" to "one local table per data type, one adapter per source, one coordinator that walks adapters in priority order, dedupes by content hash, sets is_primary at write time, and emits priority signals on material events" — through one ABC, four adapter implementations, one coordinator, and one cache TTL table that binds four modules together.
The Problem
The org has InsightPulse. Pulse owns vendor licensing, scraping compliance, and GDPR posture for ten different external feeds — board members, executives, capital raises, news, S&P / Moody's / Fitch / A.M. Best ratings, MSCI ESG, broker producer info, and more. Pulse is a real product, with real depth, and the org isn't going to throw it away to write its own scraper.
But underwriters don't underwrite in Pulse. They live in the workstation. The submission is in front of them. They need the executives panel, the news panel, the priority signals, the ratings strip — at decision time, with their submission as the anchor.
Three naïve framings, all wrong:
- Iframe Pulse into every page. Now the workstation can't make a decision when Pulse is degraded. The auth flow is Pulse's. The audit is Pulse's. The styling diverges. UWs feel like they're "in Pulse" inside the workstation, which is slow and confusing.
- Ingest Pulse's tables wholesale. Now the org has two copies of every external dataset, two refresh cadences, two GDPR-deletion paths, two compliance reviews. Pulse's schema becomes a constraint on every workstation feature.
- Make every UW page call Pulse directly. Now every page has its own retry logic, its own caching, its own fallback for when Pulse is unavailable, its own conflict resolution when manual data and Pulse data disagree. Three pages later, three different conventions.
There's also a fourth framing nobody asks for explicitly but every workstation eventually grows into: what happens when a deployment doesn't have Pulse? Or has Pulse plus a different direct vendor? Or wants to swap Pulse for an in-house enrichment service? If Pulse's API shape leaks into the workstation, every page becomes a Pulse-port-cost when that conversation comes up.
The right framing is older than any of these: the workstation owns its data contract; sources populate the contract via adapters. Pulse is one (big) adapter. Manual entry is another. Future direct-vendor connectors are more. Pages read the contract, not the source.
The InsightUW Approach
The contract is a small set of UW-owned tables. The coordinator is one file. Each source implements one ABC. Pages read tables, never sources.
One ABC: IntelligenceAdapter
Every concrete adapter is small. manual entry writes whatever the UW typed; pulse calls /api/insurance/360/insured/{duns} and maps the structured response to AdapterRecord rows; news rss parses RSS/Atom into news records; news newsapi calls NewsAPI.org. None of them write to the database. The coordinator owns persistence.
The contract: UW-owned tables, one per data type
Insured Board Member, Insured Executive, Insured Capital Raise, Broker Producer Info, News Item, Insured External Rating — same shape:
The columns workstation pages read — round type, amount, closed date, summary text — are source-agnostic. The columns the coordinator reads — source provider, source request guid, is primary, expires at — never leave the framework. A page rendering "recent capital raises" doesn't know whether the row came from Pulse, manual entry, or a future direct-vendor connector.
The coordinator: walk → persist → dedupe → emit
Five steps, none of them coupled to a source.
persist insured records: one branch per data type
The branch shape is mechanical. New data types take ~10 lines.
recompute is primary: dedupe at write time, not read time
Both Pulse and manual entry can publish "John Smith on the board." Both rows are kept (audit, source attribution). is_primary=True is set on whichever source has lower priority number at write time. Pages render is_primary=True rows by default with an "alt sources" toggle.
This is what "UW owns the contract" means concretely: conflict resolution lives in coordinator code, not in the page. Pages see the resolved view; the framework explains the resolution on demand.
emit signals on material events: priority signals are a side-effect
Signals land in Priority Signal, the same table claims-driven signals (blog #105) and news signals write to. Composite Urgency Scoring (blog #88) is the consumer; the scorer doesn't care which adapter produced which signal.
Configuration: Intelligence Adapter Config
One row per adapter, seeded on startup:
Admin can flip is active, change priorities, even raise manual entry above Pulse for a deployment that distrusts vendor-aggregated rows for board members. The contract doesn't move.
Worked Example: Sarah Reviews Acme Construction
Sarah opens Acme's submission. The board panel renders:
| Member | Title | Other boards | Source |
|---|---|---|---|
| Jane Doe | Chair | Foo Corp · Bar Inc | (primary) |
| Mike Lee | CEO | — | (primary) |
| Anita Patel | Director | Baz LLC | (primary) |
She clicks "show alt sources" on Jane Doe's row. Two rows expand:
Pulse· fetched 2 days ago · "Other boards: Foo Corp, Bar Inc, Quux Holdings"- manual entry · entered by Sarah's predecessor 3 months ago · "Other boards: Foo Corp, Bar Inc"
Manual is priority=10, lower than Pulse's 50. Manual wins is_primary by default. Sarah's predecessor's research stays canonical until she or another UW deliberately re-fetches Pulse and overwrites — and even then, the manual row stays in the table with is_primary=False so the audit trail is preserved.
She switches to the executives tab. Three executives, all from Pulse, each with a Pulse source chip and a fetched-at timestamp.
She clicks "Refresh from sources" at the panel header. The coordinator walks: manual entry (no insured-form input pending — skip), pulse (calls Pulse, returns 11 board members + 5 executives + 2 capital raises). persist insured records writes 18 rows. recompute is primary keeps Sarah's predecessor's manual board entries as primary, adds 8 new Pulse-only board members, primary'd because no manual conflict exists. emit signals on material events finds a Series E that closed 12 days ago — emits recent capital raise into Priority Signal.
The Composite Urgency Scoring on Acme's submission ticks up; the priority badge changes from "medium" to "medium-high." Sarah didn't write any of this. The framework did.
The Pulse-degraded path
Three weeks later, Pulse is in a maintenance window. Sarah opens a new submission for Beta Industries. The coordinator walks: manual entry (no input — skip), pulse (returns success=False, error_class="ConnectionError", fallback_mode=False). record fetch request writes a Intelligence Fetch Request row with outcome="failure". No persisted rows are written.
The page reads Insured Board Member for Beta — empty. The panel renders: "No data yet. [Refresh from sources] [Enter manually]." Sarah clicks "Enter manually." A modal opens; she pastes 4 board members from a 10-K excerpt the broker emailed. The form-submit path calls manual_entry.fetch_for_insured synthetically (the adapter pattern works for write-input too — the coordinator persists what manual hands it), the rows land in Insured Board Member with source_provider='manual_entry', is_primary=True. Sarah's panel shows 4 rows.
Pulse comes back the next day. The coordinator's TTL on board data is 180 days; nothing auto-refreshes. The next time Sarah hits "Refresh from sources" — or when the scheduled Pulse poller runs — Pulse returns its own board list. Most overlap with Sarah's manual entries. Manual stays primary by priority. Pulse-only rows that Sarah didn't enter get added. No data loss; no churn; no overwrite.
What's Deferred (Phase 2)
- Per-vendor direct adapters. S&P direct, Moody's direct, A.M. Best direct, NIPR direct, Capital IQ direct, MSCI ESG direct. Stubs only today — Pulse aggregates these. Half a day each when a deployment needs them.
- Adapter health monitoring as an SLA. Today,
Intelligence Fetch Request.outcome='failure'accumulates silently. Phase 2: a sweeper that fires intelligence adapter degraded notifications when failure rate exceeds a threshold over a rolling window. The data integration hub (blog #108) already cataloges these flows; the SLA layer is a small extension. - Conflict-resolution UI. Today, is primary is computed by priority. UWs with reason to prefer the non-primary row (e.g., Pulse aggregated A.M. Best at a stale date) get an "alt sources" toggle but no first-class override. A manual pin column on each contract row would let UW lock primary-status to a specific source row.
- Adapter diff narrative. When two adapters publish board membership and the lists disagree, today the UI shows both with source chips and lets the UW eyeball it. A Phase 2 narrative ("Pulse and manual disagree on 2 of 5 board members; manual missing Quux Holdings director Quentin Smith") is a small AI summarization layer over the same rows.
- Schema-evolution policy. Today, adding a new field to a contract table is a normal migration; adapters pick up the new field by extending their payload mapping. Phase 2 work: formalize this via the Data Contract table (data integration hub) so contract version + adapter version are paired.
What This Means for Underwriters
- Pulse is one big adapter, not the contract. If Pulse is replaced or supplemented in a deployment, the contract doesn't move. Pages keep working.
- Manual entry is always available. When external sources are degraded or Pulse hasn't covered a region, manual entry takes priority by default and writes through the same audit path.
- Conflict resolution is automatic and explicit. Multiple sources can publish the same row; all are kept, and is primary is set per priority order. The UI shows the resolved view with an "alt sources" toggle that explains the resolution.
- Cache TTLs are per-data-type. Board changes annually (180d). Capital raises change monthly (30d). News changes hourly (4h). The coordinator respects the right cadence per data type without page-level work.
- Priority signals are a side-effect of fetching, not a separate workflow. The framework emits Priority Signal rows on material events. Composite Urgency Scoring (blog #88) consumes them. UWs see the priority badge tick without anyone having to wire the connection.
- Adapter additions are mechanical. New source = one new file under
intelligence_adapters/, one row in Intelligence Adapter Config. No page changes, no workflow changes, no schema migration. - Data type additions are short. New data type = one new contract table, one branch in persist insured records, one entry in
CACHE_TTL_DAYS, one entry in recompute is primary. ~30 lines of code. - Audit is end-to-end. Every contract row carries source provider, source request guid, fetched at. Intelligence Fetch Request records every adapter call (success, failure, fallback). Compliance can reconstruct what UW saw and which source said it.
- The same pattern carries news (blog #105 will cite), ratings (blog #103), and broker producer info. Four modules, one framework. The cost of wiring the next external-data module to the workstation is one adapter and (sometimes) one contract table.
- Pages don't know about Pulse. The board panel, executives panel, capital raises panel, ratings strip all read UW-local tables. Page-level Pulse outage handling doesn't exist because Pulse outage doesn't reach the page.
What's Next
Claims-driven priority signals (blog #105) reuses the same Priority Signal table that the multi-adapter framework writes to — this time, the source is the org's internal claims store, not an external adapter, but the consumer (Composite Urgency Scoring) is the same.
Want to see how InsightUW reads Pulse without becoming Pulse? Request a demo.