Book Importer: the build

Engineering plan, with every load-bearing claim checked in source

Overview Engineering BKB Extraction Executive Client-facing

Build a converter that takes a brokerage's existing book from whatever source holds it and lands it in Plansight as real groups and plans: who the carrier is, what the rates are, and what the plan attributes are. Import only. There is no write-back and no live sync, and that single constraint is what keeps this a quarter of work instead of a multi-year integration.

The most important thing to understand before you start

Three of the five stages already exist in production. Open Plan reads the documents. V1's PlanfactService converts Open Plan output into V1 plan data. V1's benefit history store accepts and validates those plans. We are writing the converter and the review layer, and wiring them to things that already run. The failure mode on this project is somebody deciding to port or re-implement Open Plan or PlanfactService. Do not.

Scope, decided by Steve. Three things, and one deliberate exclusion.

The conversion answers exactly three questions per plan: who is the carrier, what are the rates, and what are the plan attributes. That is the whole target.

Contributions are explicitly out of scope. They are not plan data in Plansight, they live in the presentation layer, and they are set per scenario rather than being a fact about the plan. Pulling them into the converter would mean inventing a target that does not exist. If a source carries contribution data, ignore it. Do not add it back without a decision.

The five stages

What each one does, and what already exists.

Stage 1

Source adapters

SharePoint via Graph, BrokerageBuilder via REST, or a spreadsheet.

To build
Stage 2

Open Plan

/docid then /extract-all, async callbacks, cost telemetry.

Engine exists
Stage 3

Converter

Claude normalizes into Employer Book JSON with provenance.

To build
Stage 4

PlanfactService

Open Plan JSON to V1 fields, then the benefit history store.

Exists
Stage 5

Review queue

Low confidence rows surfaced, golden set hand-verified.

To build

Stage 1: source adapters

Each adapter's only job is to produce a pile of documents plus whatever structured hints the source offers. They are deliberately thin.

AdapterTransportAccessStatus
Document library
SharePoint
Microsoft Graph, read only, Sites.Selected Routine IT ask on the customer side No blocker. Build first.
BrokerageBuilder
Zywave AMS
An extraction agent driving credentials, not a typed client. REST surface documented; OAuth 2.0 with scope api.bkb:read where a token is granted. Baldwin confirmed they run it and is pursuing tokens for us. ThreeFlow had direct back-end extract access, so the precedent exists. Unblocked by the customer. See the BKB Extraction plan.
Spreadsheets
employer and plan lists
File upload None No blocker

Scope the SharePoint pull deliberately: current plan year only, dedupe by content hash, and skip census and enrollment files, which carry PII we have no reason to ingest for a plan conversion. Folder names are a useful employer-matching signal, cross-checked against what /docid reports.

Stage 2: Open Plan reads the documents

Open Plan already handles 11 product types plus statutory NY and NJ forms, and it is runnable locally for the spike with poetry and a dev key.

/docid exists in Open Plan but V1 has never called it

Do not assume this step is wired up. PlanfactService::apiCall() takes $planType as its first parameter, meaning V1 tells Open Plan what the document is rather than asking. The $documentId argument in that same signature is V1's own correlation id for the async callback, not Open Plan classification. Grep confirms no /docid call anywhere in the service.

Classification is therefore net-new integration work for the importer, and it is the single highest-value new capability in the whole pipeline. Extraction and verification are not the gap. The plan builder already uploads a document, extracts the plan, and puts it in front of a user to verify. What it requires is that a person tag the document to an employer and a line of coverage first, which is fine per plan and impossible across a book. Stage 2 is marked as existing because the engine exists, not because this path is built.

Decided 2026-08-09, do not re-litigate

The Open Plan schema is the canonical extracted-facts layer for every source, not just documents. The BrokerageBuilder adapter normalizes that tenant's free-text benefit items into Open Plan schema too, so the converter has exactly one target vocabulary. The reasoning is that Open Plan is the engine the company teaches to read benefit documents, and it eventually moves into Phoenix. V1 is the near-term landing target only, so V1 field derivation happens at landing rather than being baked into the stored Book. When Open Plan moves into Phoenix, converted books re-land there from the same files with no re-extraction. The V1 emitter is the only throwaway piece.

Stage 3: the converter

A standalone service, following the rating-service pattern rather than living inside V1. It takes Open Plan facts and emits the neutral Employer Book: one file per employer, carrying Open Plan schema facts, per-value provenance and confidence, and the raw source values that produced them.

Engine

Claude Opus 5 with structured outputs, so the schema is enforced at the tool-call layer and there are no parse failures to handle.

Batch API

Conversions are overnight jobs by nature, which puts the whole workload on the 50 percent batch discount.

Prompt caching

The field spec repeats on every group in the book. Caching that prefix is roughly a 90 percent saving on the largest part of each request.

Provenance

Every value carries where it came from and how confident the model was. That is what feeds the review queue and the customer-facing source annotation.

First task, before any conversion code

Build the field-spec exporter. A script run against the local V1 repo that dumps Plantypes::tableColumns() output per line of coverage to JSON. It is both the converter's vocabulary source of truth and its cached prompt prefix. An external service cannot call the PHP enumerator at runtime, so this has to be a build artifact. It is small and it unblocks everything else.

Stage 4: landing in V1

app/Services/PlanfactService.php is 7,457 lines and already does this job in production. It carries an apiCall() client plus a parseQuote dispatcher and 11 product parsers (medical, dental, vision, group life, voluntary life, short term disability, long term disability, accident, critical illness, hospital indemnity, stop loss), handling network mapping, critical illness tobacco rate bands, and bounding box attachment. It is the live AI-quote path behind AIQuoteJob and QuoteController.

Reuse it through V1. Never port it. The converter should emit extraction-format payloads so PlanfactService normalizes server-side, which also means the import benefits from every future fix to that parser.

Corrected 2026-08-09: the bulk-Excel bridge does not exist

An earlier version of this plan assumed V1 could take a spreadsheet of benefit history and import it. It cannot. routes/web.php:56 is quoteBenefitHistoryQuotesUpload, a captcha-gated document attachment upload for external carrier links. It never parses a spreadsheet into data, and no other code path in V1 does either.

The real landing path is a scripted operator session driving POST rest/app/group/{groupId}/benefitHistory/{quoteId?} (routes/web.php:922, BenefitHistoryController@store). This needs zero V1 code changes to demo, and every plan still passes Quote::validateQuoteData() server-side, which the imagined Excel bridge would have bypassed. Once the shape is proven, one dev card adds a real import endpoint.

Plans travel as packages, not rows

The single most important shape decision in the Employer Book schema.

The naive read of this project is that it lands a pile of plan records. That would be a mistake, because V1 already thinks in packages and the importer should emit them. Two existing mechanisms prove the pattern, and both are live on master today:

RFP templates: the portable bundle

RfpTemplateController copies a whole RFP, its plan groups, and its presentation data, then sets groupId = 'none' (:228), detaching the bundle from any employer. It is stamped with brokerageId, the list query carries no user filter, and effective-date attributes are skipped on copy, so it is brokerage-wide and it does not expire.

Plan library: the portable plan

QuoteDefault extends Quote, so a library entry is a whole plan with benefits and rates rather than a stub. It carries a user-given name, is Elasticsearch-indexed, and isShared plus shareWithBrokerage make it visible across the brokerage.

Design consequence for the Book schema

An Employer Book is the same shape of object as an RFP template: a coherent set of plans that can be detached from the employer it came from and applied elsewhere. Schema and emitter must both respect that.

Concretely: an employer's plans land as one bundle with a group boundary, not as independent rows that happen to share a groupId. Every plan must additionally carry enough on its own (name, carrier, type, benefits, rates) to stand up as a library entry without the bundle around it. Get this right at the schema stage and the export path, the template path, and the library path all fall out for free. Get it wrong and every one of them becomes a retrofit.

This also closes the loop with work already in the product. We import and export RFPs. If the Book is bundle-shaped, then a converted employer can seed an RFP directly, go to market, take quotes back in, and produce the presentation, with no intermediate re-entry step. The conversion stops being a migration and becomes the first step of the normal workflow.

V1 constraints that will bite

Verified against master at a8e7f76c89. Re-check line numbers before writing code.

ConstraintWhat it means for the importer
No attribute or rate tables Plan design is schemaless string bags in DynamoDB, and rates are roughly 60 flat columns on the 17,675-line app/Models/Quote.php. There is no relational structure to target.
Attribute names derive at runtime The only correct enumerator is Plantypes::tableColumns() at app/Helpers/Plantypes.php:1483. Secondary lists elsewhere in the codebase have drifted. Never use the map at Quote.php:657.
36 plan types The registry is config/plantypes.php. Anything outside it lands as a plan shell plus documents rather than failing the import.
Validation is server-side Every import must pass Quote::validateQuoteData(). Treat a rejection as a review-queue row, not an exception.
Stop loss rates are mutated in place V1's cost calculation folds fees into the stored columns (Quote.php:9489-9495). Write raw carrier rates only, or the numbers drift every time cost is recalculated.
Rate tier fields are paired rateTierSchedule (string) and rateTiers (int) must both be set. Setting one is a silent data problem.
No smoker rate dimension V1 has none. Source data that carries smoker-differentiated rates needs an explicit collapse rule decided once and documented.
Write surfaces are session-authed The public API v1 is read only. Writes go through session-authed web routes, which is why the first landing path is a scripted operator session.

Phases and effort

Demoable in weeks. Production-grade in about a quarter.

Phase 1: book inventory

1 to 2 weeks · no blockers

Field-spec exporter, then employer groups and plan shells: names, types, carriers, plan year, policy numbers, subscriber counts, statuses. Includes the two per-source mapping tables with review screens, plan types and carriers, the latter expecting fuzzy name matching with an A.M. Best cross-check. Deliverable is already a sales demo: here is your whole book in Plansight.

Phase 2: money

2 to 4 weeks

Rates, rate guarantees, fees. Fixed, tiered, and volume rate shapes are clean; age-banded complex rates need parsing; self-funded follows the raw-rates-only rule above. Per-source tier-name mapping reuses the Phase 1 review screen pattern. Borrow the transferable BenefitPoint patterns here: capability probe, graceful degradation, audit log.

Phase 3: plan design

4 to 8 weeks · the long pole

Item-name matching of source benefit items to V1 field keys, value normalization into V1's typed token sets, and cross-validation against document extraction wherever both a document and a structured source exist for the same plan. Review queue UI for low-confidence rows. Golden-set verification: hand-check a fixed number of plans per line of coverage before declaring any tenant converted.

Phase 4: deliberately not planned

Requires a signed reason to revisit

Write-back to the source AMS. Keeping the old system alive post-conversion recreates BenefitPoint-scale cost for a customer we are actively trying to move off that system.

Why this is cheap relative to BenefitPoint

The in-house yardstick is 324 files and roughly 55,200 lines.

AxisBenefitPointBook Importer
DirectionBidirectional push and pull with renewal matchingImport once, re-runnable, no push
TransportLegacy SOAP plus REST, per-customer WSDL quirksOne modern REST API or a document pull
Rate mappingExact per-ID fidelity in both directions. RateData.php alone is 6,992 lines.Six shapes to V1 columns, one direction, fuzzy with review is acceptable
Attribute mappingPer-attribute IDs wired into 35 config filesName-level matching, AI normalization, document fallback
Failure toleranceA failed push corrupts the system of recordA failed import is a row in a review queue

Dead ends, do not retry

Open engineering questions

  1. Confirm parseQuote* output lands cleanly in quoteBenefitHistory. It extends Quote, so this is expected to work, but the spike must prove it rather than assume it.
  2. Confirm the plan-attribute token sets per line resolve cleanly from Plantypes::tableColumns() for every one of the 36 types, including the ones the AI reader does not cover.
  3. Per-tenant taxonomy variance is unmeasured until we see a live tenant. Plan types, benefit items, tier names, and age-band conventions all vary.
  4. Rate limits and sandbox availability on the BrokerageBuilder API. Only Zywave can answer this.

Expect the "rebuild it natively in V1" conversation

Roster hit exactly this, and the pushback landed on rebuilding natively. The dev card for the import endpoint should cite that precedent directly and be explicit that the recommendation here is an external service first, promoted to a V1-native package only if conversion volume justifies it.

Verified in source on August 10, 2026 against ~/Code/plansight-v1 master a8e7f76c89: PlanfactService at 7,457 lines with 11 product parsers plus a dispatcher; Plantypes::tableColumns() at Plantypes.php:1483; 36 entries in config/plantypes.php; benefitHistory.store at routes/web.php:922; quoteBenefitHistoryQuotesUpload at routes/web.php:56; Quote.php at 17,675 lines.

Full working documents live in the Plansight Brain vault under BrokerageBuilder Integration/.