Inspiration

A renters insurance quote takes about four minutes and asks maybe twelve questions. One of them is "do you have working smoke detectors?" You click yes. Nobody checks. The insurer prices an apartment they have never seen, and you never find out that the space heater you keep next to the curtains is the single most likely reason your unit burns down.

We were building the Federato side at the same time, an underwriting agent that reads a carrier's submission book against its appetite guidelines, and it turned out to be the same problem from the other chair. Underwriters were also making decisions on a stack of fields a broker typed. TIV, construction type, year built, loss history. All of it self-reported, none of it verified, and a decision worth tens of thousands of dollars sitting on top.

Both ends of the transaction run on text somebody typed. So we built the camera that checks, and the engine that shows its work.

What it does

Retrofit is one deterministic underwriting engine with two front doors.

The phone app (renters). You name the room, pick a term, and sweep the room with your camera while turning in place. Frames auto-capture as your heading advances and a coverage ring fills. The model lists objects from a fixed vocabulary and then code takes over. Pure geometry places each object by compass bearing, so a heater within ±20° of fabric becomes a heaterNearCombustible hazard. "No smoke detector" only counts against you if the ceiling was actually visible; otherwise it becomes a question instead of a penalty.

Anything the model wasn't confident about lands on a radar ring for you to confirm or dismiss. Then a handful of plain-language questions, one at a time. The verdict screen gives you the estimate, the factor breakdown, the specific rule that decided it, and the fix. Then Verify my fix: move the heater, photograph it, and get re-priced on the spot.

The console (underwriters). It ingests all 158 live submissions from Federato's API and ranks them: 1 FIT, 11 REFER, 146 DOES_NOT_FIT, with 120 of those knocked out at triage for not being property business. Every factor in every score quotes its row from APPETITE_GUIDELINES.pdf verbatim. A panel called "How the agent got here" shows the queries the agent ran, why it ran them, and the paths it considered and rejected. Paste a broker's email into the account and it comes back as typed fields, each carrying a verbatim quote that code re-finds in the original text before the value is allowed anywhere near the score. We did this live: a reply about a 1998 building and "about $64.5M" in TIV moved Lakeside Medical Group from rank 8 to rank 2.

The rule we never broke, on either side: AI sees, code decides. No model picks a number, a verdict, or a price. Models read photos and prose; the engine does every rollup, rule, and dollar.

How we built it

Eight workspaces in a TypeScript monorepo. packages/engine is the core and has zero I/O, rulebooks, rating tables, and every stage from rollup to rank, which is what makes it testable at the volume we wanted.

The Federato agent plans its own queries instead of running a fixed script. It reads the schema into a resource graph, collects every field the rulebook needs, and locates each one through a synonym table and then a shortest-path search. Anything it can't place stays visibly unmapped in the trace rather than quietly defaulting. It triages all 158 submissions in one query, then pulls all 27 property policies fully hydrated, insured, claims, exposure units, buildings, in a second one. Four queries, 9.2 seconds, whole book.

Pricing isn't invented. We fitted the rating factors by least squares against the 27 real policies (R² 0.72), constrained them monotonic so they stay defensible, and froze them into JSON. Explanations come from a deterministic template over the fired rules, so all 158 exist instantly and none of them can contradict the numbers. A model may polish the wording, but a positional guard rejects any edit that changes a digit or the recommendation.

Then we tried to break it three ways. Property tests against the engine's own invariants. A differential run against a naive second implementation written by an agent that never saw our engine code, working only from the PDF and our written interpretation contract. And a second-opinion layer where a different model gets the guideline text and the rolled-up facts, never our score, tier, or verdict, and rules on the account independently.

Final numbers: 10,000,000 cases, 0 invariant violations, 0 disagreements. Second opinion agreed on 1,331 of 1,332 (99.9%), including all 38 real property accounts.

Challenges we ran into

The API documentation was wrong about its own operator. The docs describe over as a GROUP BY. The deployed handler returns one row per record with a constant sum. We wrote the planner to detect this, strip the operator, and record the reason in the trace rather than silently shipping bad rollups.

A query that returned zero rows instead of an error. Filtering exposure_units.location.state: CA as a dot-path through an array matches nothing, no error, no warning, just an empty result that looks exactly like "no accounts in California." The $elemMatch form of the same filter returns 47. The planner now rewrites the clause on zero results and logs which form worked.

Real data broke things synthetic data never could. Our first pass looked clean. Then we reviewed the engine against actual Federato records and found 39 confirmed defects. Every hydrated building was being assigned the first location's id, which put the primary risk state wrong on 11 of 27 accounts. Only 38 of 158 submissions were actually being stored. And a date conflict present on all 27 accounts was being treated as blocking, which meant the entire book had zero FIT accounts, and we'd been telling ourselves the guidelines were just strict.

Our first differential run reported 20,662 invariant violations, which was alarming until we traced it. It wasn't a bug in either implementation. Our interpretation contract specified how to compare state codes and construction types and said nothing about submission type, so the two implementations filled the gap differently, one case-folded "NEW_BUSINESS", one didn't. The contract was the defect. We closed it on underwriting grounds and brought each side to the contract separately, never by copying the other.

The Gemini key ran out of prepaid credits mid-run. HTTP 402, 706 verification cases unanswered, and our reply-extraction accuracy check silently produced a meaningless 41%, clean replies scored 0/15 while vague ones scored 7/7, because an empty answer is accidentally "correct" when nothing should be extracted. We threw the number out and the console now reads "Not measured" rather than quoting it. Every text call moved to Claude Sonnet 5, which works live; Gemini now only gates the phone's vision calls.

We cut the 3D coverage dome. The libraries were never installed and nobody could verify a 3D render inside Expo Go in the time we had, so we shipped the 2D Skia ring, which was the fallback we'd named in advance.

Nobody has run the phone app on a physical iPhone. We drove every other screen end to end in a browser against the live API instead, which caught two real bugs a code read had missed: a screen-reader nested-heading conflict, and a contrast failure where a red verdict pill's text landed on a dark gradient it was never designed for.

Accomplishments that we're proud of

Ten million verification cases at zero violations and zero disagreements, and the fact that those zeros were earned, the first run was 20,662 and 1,369, and we chased every one of them down.

An agent that plans its own queries from a live schema and writes down what it rejected. The "considered and rejected" list is the part underwriters actually reacted to, because it's the difference between a tool that answers and a tool you can audit.

A second model, shown only the guidelines and the facts, agreed with our engine on 99.9% of cases, and the single disagreement turned out to be a genuine hole in the source PDF (at exactly 50% acceptable construction, neither the Acceptable nor the Not Acceptable condition holds). We didn't paper over it. It's on the rule card.

And the accessibility work is real, not a checkbox: colour never carries meaning alone, contrast ratios are enforced by a test in the design package, the coverage ring reads aloud as text, targets are 44×44pt, and anyone who can't physically turn in place gets a three-photo upload path that reaches the same verdict.

What we learned

Differential testing has a ceiling. Two implementations built from the same written contract will share that contract's blind spots perfectly. Layer B caught every arithmetic and boundary error and could not have caught a shared misreading, that's a different kind of test, and you have to know which one you're running.

Test against real records early. Our synthetic suite was green while real data had 39 defects in it, because those bugs lived in how records get read, not in the scoring math.

Writing interpretations down as a contract was the highest-leverage hour we spent. The guidelines are genuinely ambiguous, "primary risk state" has no field and policies span four states, and every ambiguity we wrote down became a thing we could test, defend, and show the underwriter. Every one we didn't became a bug.

Put the boundary in the right place. Models are very good at "there is a heater and a curtain in this frame" and bad at "therefore the premium is $340." Keeping every number on the code side is why we can run ten million cases and why no demo ever produced a figure we couldn't trace.

"Not measured" is a real answer. We had a number for extraction accuracy. It was garbage, and shipping it would have been worse than shipping nothing.

What's next for Retrofit

Run the sweep on actual hardware, the camera and compass logic is the one thing no amount of browser testing can validate, and it's the first thing we'll do with a phone in hand.

Make enrichment count. We already fetch FEMA flood zones and fire-station distance and show them on every account, but nothing reads them yet. The obvious next rule refers any location sitting in a FEMA A or V zone.

Replace the invented tenant rates with real ones. They're currently labelled "estimate" everywhere they appear, and they should stop being an estimate.

Re-run the 30-reply extraction check now that it's on Claude, so we can quote an accuracy number we believe.

And the thing we most want to build: point the same camera at a commercial submission and check a broker-typed "sprinklered: yes." The renter app and the underwriting console are already the same engine. Verifying the broker's claim the way we verify the renter's is the same idea, one rung up.

Built With

Share this project:

Updates

Submission history