One microVM Per Claim: Isolating Insurance Claims Processing Per Carrier
The product is an insurtech platform. Four carriers are live, six more are in procurement, and the pipeline does roughly this: a first notice of loss arrives as a PDF form or a webhook payload, an adjuster uploads eleven photos of a crumpled bumper taken on a phone in a parking garage, a body shop faxes an estimate, a claimant's attorney sends a PDF of medical bills, and somewhere a police report shows up as a scanned image of a form that was itself photocopied twice. You OCR all of it, run a rules engine over the extracted fields, and — increasingly — hand the ambiguous parts to an LLM to pull structure out of prose that a human wrote at 2am at the roadside.
Every carrier's contract, and every carrier's security questionnaire, says the same thing in different words: our claims data must never touch another carrier's. And in most of these platforms, at the moment that promise is made, all of it is being parsed by the same pool of worker processes on the same handful of machines, writing intermediates into the same /tmp, sharing the same OCR model cache, and reporting exceptions to the same APM vendor with the payload attached.
I'm Ajay; I build PandaStack, a Firecracker microVM platform, so I spend most of my time on the question of what a tenancy boundary is actually made of. This post is about why the shared worker pool is the wrong boundary for claims specifically, what the per-claim microVM shape looks like in practice, and which parts of the compliance story it makes cheap to evidence — as opposed to which parts it does not touch at all, because there are several and I would rather you hear them from me.
What a claims pipeline actually ingests
It is worth writing down the real input list, because it is longer and stranger than the architecture diagram suggests, and every line of it is a file chosen by someone outside your company:
- FNOL forms — PDFs, sometimes AcroForm-filled, sometimes flattened, sometimes a scan of a printed form somebody filled in with a pen. Also XFA forms, which are a whole separate format wearing a PDF costume.
- Adjuster and claimant photos — JPEG, HEIC, occasionally TIFF from an older estimating tool, with EXIF blobs that nobody validates and that your thumbnailer will happily parse.
- Medical bills and records — CMS-1500 and UB-04 scans, itemized statements, and increasingly PDFs exported from a provider portal that embed fonts, JavaScript, and an XFA layer.
- Repair estimates — vendor exports in .xlsx, .csv, or an EMS/CIECA-flavoured file, plus the occasional .doc from a shop that has not upgraded since 2009.
- Police reports and legal correspondence — scanned images of photocopies, and demand letters that are Word documents with tracked changes still in them.
- Carrier-side reference data — policy extracts, coverage tables, prior-claim history pulled from the carrier's own systems over a credential the carrier gave you.
Notice the property that binds these together. A claims pipeline is an API that invites strangers to upload files they chose, into a parser you did not write, so that a machine holding several other carriers' data can open them. Stated that way, the shared worker pool starts to look less like an architecture and more like a dare.
Why the shared worker pool is the wrong boundary
The failure people plan for is a container escape, and that is the least likely thing on the list. What actually produces cross-carrier incidents in document pipelines is mundane, and none of the four routes below requires anyone to be attacking you.
- The temp file. Parsers and OCR engines write scratch files: rasterized pages, extracted embedded images, intermediate TIFFs, model input tensors. Most of them clean up on the happy path. On a timeout, an OOM kill, or a SIGSEGV in a native decoder, they do not — and now Carrier A's medical bill page is sitting in /tmp on a box that is about to run Carrier B's job as the same UID. Cleanup you believe runs is not a control; it is a hope with a cron job attached.
- The shared model cache. One OCR or layout model directory, mmapped by every worker, is a fine optimisation until somebody adds a per-carrier fine-tune or a per-carrier dictionary of provider names and adjuster codes. Then the cache directory is a cross-tenant read surface and the thing making it fast is the thing making it leaky.
- The exception reporter. A parser throws on a malformed page and your crash reporter attaches locals, the request context, and helpfully the first few kilobytes of the document. A claimant's diagnosis code is now in a third-party SaaS, retained for ninety days, searchable by your entire engineering org. I have watched this be the actual mechanism of the actual disclosure more often than any exploit, and nobody ever files it as a breach.
- The process itself. One address space, one environment block, one set of open file descriptors, one connection pool. A use-after-free in a native image decoder, a mis-scoped cache key, a connection returned to the pool with a session variable still set — any of these hands one carrier's bytes to another carrier's request, and the language runtime will not stop it, because a language runtime is a tidiness mechanism, not a tenancy boundary.
The specific attack surface of document parsing
Document and image parsing is one of the most reliably productive CVE veins in software history, and for a structural reason: these are permissive binary formats, decades old, implemented in C, with decoders that must handle every malformed variant ever emitted by a scanner vendor, because rejecting real-world files is commercially unacceptable. libtiff, ImageMagick's delegate chain, Ghostscript's PostScript interpreter, the JBIG2 and JPEG 2000 decoders that PDF pulls in, the font rasterizers underneath all of it — every one of them has a long, ongoing history of memory-corruption findings, and every one of them is somewhere in the dependency graph of your OCR step whether you chose it or not.
Two things make claims worse than the average upload endpoint. First, the files are adversarially available: anyone who can open a claim can submit a document, and opening a claim is deliberately easy because friction there costs the carrier customers. Second, the pipeline is designed to try hard. A generic uploader can reject a file it does not like; a claims pipeline is expected to squeeze text out of a fax of a photocopy, which means falling back through several parsers, several rasterizers, and a Ghostscript invocation, until something produces characters.
You cannot validate your way out of this, because deciding whether a file will break a parser requires running the parser. Magic-byte checks and file-extension allowlists stop the lazy cases and nothing else. The only durable answer is to make the parser run somewhere you can afford to lose, and to make "somewhere" contain exactly one carrier's data.
LLM extraction widens the surface rather than narrowing it
Adding a model to read the ambiguous fields does not replace the parser stack; it sits on top of it, and it brings its own problems. The document text is attacker-controlled input flowing into a prompt, so a demand letter can contain instructions addressed to your extractor. If that extractor has tools — look up the policy, query prior claims, call the carrier's API — then prompt injection is a lateral movement primitive, and the blast radius is whatever credentials the executing process holds. In a shared worker, that is every carrier's credentials. In a per-claim VM that holds one carrier's scoped token and nothing else, the worst case is bounded to the claim you were already processing.
The PII and PHI overlap nobody scoped for
A property and casualty carrier is not a healthcare organisation, which is the sentence people reach for right before their auto liability pipeline ingests an itemized hospital bill with diagnosis codes, provider NPIs, and dates of service on it. Bodily injury claims mean medical records. Workers' compensation means medical records by definition. The moment your platform is handling those documents on a carrier's behalf, you are in a regulated-data conversation whether or not the word HIPAA appears in the statement of work — alongside state insurance data-security rules, and the usual pile of PII: names, addresses, dates of birth, driver's licence and VIN numbers, and bank details for the settlement payment.
The point of a per-tenant VM here is not that it makes you compliant. It does not, and no infrastructure vendor including mine can. The point is that it makes several claims in your control narrative short, mechanical, and evidenced by data you already emit, rather than long, procedural, and dependent on code review. "Carrier data is segregated during processing" becomes "each claim is processed in a dedicated hardware-virtualized guest, created at T, destroyed at T+n, with this sandbox ID, whose network policy permitted these three destinations." That is one row in a table. The alternative is a paragraph explaining your worker's variable scoping to somebody whose job is to not believe you.
The architecture: one microVM per claim
The shape is one sentence: create a fresh microVM, put one claim's documents in over a host-side channel, run the extraction and rules pass inside, take a structured result out, and let the machine cease to exist. The document's lifetime and the machine's lifetime are the same interval, and that interval belongs to exactly one carrier.
The reason this is a microVM rather than a container is the usual one. A container shares the host kernel, so two claims are separated by namespaces and cgroups — which is a real boundary against accidents and a thin one against a memory-corruption bug in a decoder that reaches kernel-adjacent code. A Firecracker guest boots its own kernel under KVM and exposes a handful of virtio devices instead of the full Linux syscall surface. It is the boundary AWS put between Lambda tenants, and it is the one worth having when the input is a file a stranger chose.
# claims_worker.py -- one claim, one machine, one lifetime.
import json
from pandastack import Sandbox
# The extraction step: OCR + field extraction + this carrier's rules
# version. It runs inside the guest, next to the hostile PDF, and it
# writes exactly one structured artifact back out.
EXTRACT = """
import json, os, subprocess, sys
src, dst = sys.argv[1], sys.argv[2]
# ocrmypdf/tesseract/ghostscript all live in here, where a segfault
# is a boring event rather than an incident.
text = subprocess.run(["pdftotext", "-layout", src, "-"],
capture_output=True, timeout=300).stdout.decode(
"utf-8", "replace")
fields = extract_fields(text) # your extractor
result = apply_rules(fields, os.environ["RULES_VERSION"])
json.dump(result, open(dst, "w"))
"""
def process_claim(carrier: str, claim_id: str, filename: str,
document: bytes, rules_version: str) -> dict:
sbx = Sandbox.create(
template="base",
# The TTL is the reaper. Ten minutes is longer than any honest
# extraction and shorter than anyone's patience, and it holds
# even if this orchestrator process dies mid-claim.
ttl_seconds=600,
# Tag the machine, never the claimant. These fields are
# queryable and show up in list APIs and operational logs.
metadata={"carrier": carrier, "claim_id": claim_id,
"rules_version": rules_version},
)
# Documents go in over the host-side filesystem API. The guest is
# never handed a token that can talk back to the control plane, so
# a compromised parser has nothing to enumerate with.
sbx.filesystem.write("/work/in/" + filename, document)
sbx.filesystem.write("/work/extract.py", EXTRACT)
r = sbx.exec(
"cd /work && mkdir -p out && "
"RULES_VERSION=" + rules_version + " "
"timeout 420 python3 extract.py in/" + filename + " out/claim.json",
timeout_seconds=450,
)
if r.exit_code != 0:
# A crashed parser is a routing decision, not a page. Send the
# claim to human review and keep the stderr tail, redacted.
return {"status": "needs_review", "carrier": carrier,
"claim_id": claim_id, "stderr": r.stderr[-4000:]}
# Structured result out through the same host-side channel. The
# guest never writes to your database; it emits an artifact and the
# trusted orchestrator decides what to do with it.
result = json.loads(sbx.filesystem.read("/work/out/claim.json"))
# No kill() here on purpose: the TTL owns the machine's death, so
# there is no code path -- exception, deploy, OOM in this process --
# where a guest holding claim documents outlives its window. Call
# sbx.kill() as well if you would rather not pay the idle tail.
return {"status": "ok", "carrier": carrier, "claim_id": claim_id,
"sandbox_id": sbx.id, "rules_version": rules_version,
"fields": result}Two details carry most of the weight. The guest holds no identity of yours — no platform key, no object storage credential, no database connection string — so full compromise of the parser yields one claim's documents and nothing that reaches a second claim, let alone a second carrier. And the result crosses the boundary as a structured artifact pulled by the host, not as a write the guest performs, which is friction disguised as a feature: it is precisely why the guest needs no credentials.
Per-carrier egress allowlists
Isolation stops one claim from reading its neighbours. It does nothing about a claim's own workload posting its own documents to somewhere it should not. That is the second control, and it is the one teams most often skip: default-deny outbound, with an allowlist derived from the carrier's declared integration endpoints. A claim workflow for Carrier A should be able to reach Carrier A's policy API and its document store, and absolutely nothing else — not the public internet, not your own control plane, and not Carrier B's endpoints, which is the failure mode a fleet-wide firewall rule quietly permits.
This is where per-sandbox networking earns its keep. On PandaStack each sandbox gets its own Linux network namespace, veth pair and TAP device, out of 16,384 pre-allocated /30 subnets per agent host, so "this machine may talk to these two hostnames" is a property of one claim's VM rather than a firewall rule somebody has to remember to remove after the pilot ends. Three things to get right alongside it: point the guest at a resolver that only answers allowlisted names, because open DNS is a covert channel with a comfortable payload budget for a policy number; block the cloud metadata endpoints, because those hand out your ambient identity rather than the carrier's; and log every denial, because a blocked outbound connection from a claims extractor is never noise — it is either a broken allowlist or a finding, and you want to see both the same day.
Per-carrier rules engine versions, without the dependency fight
Carriers do not upgrade in lockstep. One is on your rules DSL v7 with a bespoke bodily-injury ruleset, another is pinned to v5 because their actuarial team validated it and revalidation is a quarter of work, and a third is piloting v8 on a slice of traffic. In a shared worker pool this becomes a dependency graph problem: one Python environment, one set of native libraries, one OCR engine version, and a v5 carrier's behaviour changing because a transitive dependency bumped during an unrelated deploy.
With a VM per claim, a rules version is a template snapshot. Each carrier's job restores the exact baked generation their contract names, with its own interpreter, its own native libraries, and its own model files, and no carrier's upgrade is a coordination event for anyone else. This also removes an entire genre of support ticket, which is the one that starts "the same claim scored differently on Tuesday."
Reproducing a disputed decision eighteen months later
Claims decisions get disputed, and the dispute arrives long after the deploy that made the decision. A regulator asks why a claim was flagged for special investigation. A litigator asks how a settlement figure was derived. The carrier's own audit asks whether the model that read a medical bill was the one their committee approved. In all three cases the question is the same: show me what ran.
"We think it was around release 4.11" is not an answer. What you want on file, per claim, is the snapshot generation the guest restored from, a digest of the extraction script and the ruleset, the input document hashes, and the sandbox ID with its create and destroy timestamps. Then reproduction is mechanical: restore that generation, feed the same document, get the same output — because the environment is byte-identical rather than rebuilt from a lockfile that resolved differently this year. On PandaStack that restore is the normal create path, so replaying an eighteen-month-old decision costs the same as processing a new claim.
Shared worker vs container per claim vs microVM per claim
- Blast radius — Shared worker process: one poisoned PDF that achieves code execution reaches every claim in flight on that box, every temp file left behind by earlier jobs, and every credential in the process environment, which in a multi-carrier platform means more than one carrier's. Container per claim: the process boundary is real, so an ordinary parser crash is contained, but the host filesystem, the shared model cache mount and the node's service-account identity are usually still reachable. MicroVM per claim: a compromise owns a disposable machine containing one carrier's single claim, with no credential of yours on it.
- Kernel boundary — Shared worker process: none. Two claims are separated by variable scope and good manners. Container per claim: namespaces and cgroups over one shared host kernel, so a kernel-reachable bug in an image or font decoder is simultaneously a host problem and a neighbour problem. MicroVM per claim: a dedicated guest kernel under KVM, with the host reached only through a small virtio device surface — a hardware-enforced boundary rather than a policy one.
- Start cost — Shared worker process: effectively zero, which is exactly why everyone starts here and why the boundary erodes. Container per claim: cheap, with image-pull latency on a cold node and a real cost if your image carries an OCR model. MicroVM per claim: on PandaStack a create is 179ms p50 and about 203ms p99, because every create restores a baked snapshot (~49ms for the restore step itself) rather than cold-booting; the ~3s cold boot happens once, at bake time.
- Audit story — Shared worker process: attribution means grepping interleaved logs by claim identifier, which is itself a disclosure risk, and "segregated" is a paragraph about code. Container per claim: better, though pods are frequently reused across jobs and logs aggregate by deployment. MicroVM per claim: one claim, one machine ID, one lifetime, one egress policy, one snapshot generation — the audit entry names a machine instead of a claimant, and it is a row rather than an essay.
- Residue and retention — Shared worker process: temp files, page cache, core dumps and swap all outlive the job on a long-lived host, so correctness depends on cleanup running every single time. Container per claim: the writable layer goes away; host swap, core dumps routed to a host crash handler, and shared log pipelines do not. MicroVM per claim: the guest's memory and disk stop existing together at TTL expiry, as one event you can point at.
The start-cost row is what makes this practical rather than aspirational. A fifth of a second in front of an OCR pass that takes seconds to minutes is measurement noise, and the economics are better than the always-on version because an idle carrier costs nothing — you create on demand instead of keeping a warm worker pool per tenant. The wrong shape is a VM per page or per API call: pick the unit a claims manager would recognise, which is one claim or one document, not one HTTP request inside it.
What this does not fix
- Your own correctness. The boundary is between carriers, not between you and the carrier. A rules bug that denies a valid claim executes perfectly and is fully authorised. Isolation is not correctness, and in claims the correctness bugs are the ones that end up in a regulator's letter.
- The data after it leaves the guest. Extraction's entire purpose is to produce structured fields and put them in your database. Isolating the parser does not isolate the diagnosis codes it emitted; that is a storage, encryption, access-control and retention problem, and it is usually the bigger one.
- Over-scoped carrier credentials. If the carrier's integration token can read every policy in their book because narrowing it generated support tickets, a perfectly isolated ten-minute machine still holds a very interesting credential for ten minutes. Scope is a product decision; isolation only bounds who can steal it.
- Your intake surface. Rate limits, file-size caps, page-count caps and per-carrier concurrency limits still matter — a 40,000-page PDF is a denial-of-service on your capacity even when it cannot escape the machine it detonates on.
- Compliance itself. A per-tenant VM makes segregation, retention and attribution cheap to evidence. It does not produce a certification, an agreement, a risk analysis, or a trained workforce. Those remain yours.
The question an auditor asks is never "could carrier data have mixed." It is "show me the machine, the window it existed in, and the code that was running on it." A VM per claim answers all three by construction.
The summary
Claims processing has the unusual property that its input is chosen by strangers, its parsers are the most CVE-dense software in common use, its payloads carry medical and financial identifiers belonging to people who never signed up for your product, and its output gets litigated years later. A shared worker pool loses on all four counts at once: the blast radius spans carriers, the kernel boundary is shared with the decoder that crashed, the residue outlives the job, and the audit story is a paragraph about variable scope.
Give each claim its own hardware-virtualized guest. Put the documents in over a host-side channel, give the guest no credential of yours, deny egress by default and allowlist only that carrier's endpoints, pin the carrier's rules version to a snapshot generation so upgrades are not a coordination event, record the machine ID and snapshot generation with the decision so you can reproduce it in eighteen months, and let a TTL destroy the machine and everything on it as a single event. At a 179ms p50 create, that boundary costs less than the first page of OCR. Then go and narrow the carrier credential you asked for, because that is the control deciding what the whole thing was worth stealing.
Frequently asked questions
Why isn't a container per claim enough to separate carriers?
A container gives you a real process boundary and it is a genuine improvement over a shared worker pool, but every container on a node shares one Linux kernel. Claims processing spends its time inside native decoders — PDF rasterizers, JBIG2 and JPEG 2000 codecs, TIFF readers, font engines, Ghostscript — which are historically the richest source of memory-corruption bugs in common software, and they are being fed files that anyone who can open a claim gets to choose. A bug in that path that reaches kernel-adjacent code is simultaneously a host problem and a neighbour problem. Containers also tend to share the things that make them fast: a model cache mount, a node service-account identity, a host log pipeline. A Firecracker microVM boots its own guest kernel under KVM and reaches the host only through a small virtio surface, so a successful exploit owns a disposable machine holding one carrier's single claim.
Does a per-claim microVM make my platform HIPAA or state-regulator compliant?
No. Compliance is a program — risk analysis, policies, agreements, training, access reviews, incident response, retention — and no execution environment produces it. What a per-tenant VM boundary does is make several specific assertions cheap to evidence rather than expensive to argue. "Carrier data is segregated during processing" becomes a machine ID with create and destroy timestamps. "Data is not retained beyond processing" becomes a TTL expiry event that destroyed the guest's memory and disk together. "We ran the approved ruleset" becomes a snapshot generation digest. Those are rows in a table instead of a paragraph asking an auditor to trust your worker's variable scoping. The certification, the business associate agreements where medical records are involved, and the retention policy remain the operator's job.
How do I handle per-carrier rules engine versions without dependency conflicts?
Make each rules version a template snapshot rather than a branch in one shared environment. Carriers do not upgrade in lockstep — one is pinned to an actuarially validated version, one is piloting the next, one has a bespoke ruleset — and in a single worker pool that becomes one interpreter, one set of native libraries, one OCR engine, and a carrier's behaviour silently changing when a transitive dependency bumps during an unrelated deploy. With a VM per claim, the job restores the exact baked generation the carrier's contract names, so upgrades stop being coordination events and "the same claim scored differently on Tuesday" stops being a support ticket. It also gives you the artifact you need for reproduction, because the version that ran is a snapshot you can restore rather than a lockfile you hope still resolves the same way.
Can I snapshot a warm VM after loading a claim to make processing faster?
No, and this is the mistake worth naming explicitly because it is a tempting optimisation. A Firecracker snapshot's memory file is a byte-for-byte freeze of guest RAM, so a snapshot taken after a document was parsed contains that document — decoded pages, parser scratch buffers, page cache, and any credential the guest held — and every machine restored from it inherits all of it, including machines processing a different carrier's claim. Snapshot the clean template, which is where the latency actually lives, and inject the claim after restore. And treat any snapshot taken from a guest that touched claims data as a claims-data artifact in its own right: same encryption, access control, retention schedule and data inventory entry as the original PDF. Hibernation is a snapshot with a friendlier name.
Isn't one VM per claim too expensive at claims volume?
It depends entirely on what you call a claim. On PandaStack a create is 179ms p50 and around 203ms p99, because every create restores a baked snapshot rather than cold-booting — the roughly 3s cold boot happens once, at bake time. Set a fifth of a second against an OCR and extraction pass that takes seconds to minutes and it is measurement noise. The economics are also better than a warm worker pool per carrier, because an idle carrier costs nothing: you create on demand and the machine dies at TTL. Capacity is bounded by host CPU and memory rather than network slots, since each agent pre-allocates 16,384 /30 subnets. Where it does get expensive is if you pick the wrong unit — a VM per page or per API call turns setup into overhead. Isolate at the unit a claims manager would recognise: one claim, or one document.
Keep reading
- Isolating invoice OCR and extraction agents — the same parser surface, a different document type
- Processing PHI in per-job microVMs — for when the claim contains medical records
- Running jobs with customer-supplied credentials — how to hold a carrier's integration token safely
- Controlling network egress for untrusted code — the per-carrier allowlist, in detail
- Sandboxes on PandaStack — a fresh KVM-isolated machine per claim in 179ms
49ms p50 cold start. Fork, snapshot, and scale to zero.