Skip to content
>GLB_
Go back

Why flat-file rosters break: CSV over SFTP in healthcare data pipelines

Part 1 of 7 — Roster API Series: migrating member-file ingestion from CSV over SFTP to a FHIR API.

If you work in value-based care, you have shipped a roster file. A CSV with one row per patient, dropped on an SFTP server every month, picked up by a vendor job at 2 a.m. It defines who the vendor processes: which patients get risk insights, quality gap reports, care management outreach. It also, very often, defines what you get billed for.

It works. Until it doesn’t. This series takes that pattern apart and rebuilds it as an API, using a public standard (HL7 Da Vinci ATR) as the target. This first post is about why the flat-file pattern breaks — not in theory, but in the specific ways I’ve seen it break.

The archetype

Strip away vendor branding and almost every roster integration looks like this:

┌──────────────┐   export    ┌─────────┐  SFTP put   ┌──────────────┐  daily cron  ┌────────────┐
│ Source (EHR, │ ──────────► │ roster  │ ──────────► │ vendor SFTP  │ ───────────► │ vendor app │
│ payer feed,  │             │ .csv    │  key auth   │ /inbound/    │   pickup     │  (cohort)  │
│ spreadsheet) │             └─────────┘             └──────────────┘              └────────────┘

The file usually carries:

ColumnPurpose
mrnPreferred identifier — the source system’s medical record number
first_name, last_name, dobFallback identity when no MRN is present
plan_typeWhich program/risk model applies (Medicare Advantage, ACO, Medicaid, commercial)
per-product flagsBooleans that enable or hide a product for that patient

And the contract around the file is almost always the same three rules:

  1. One file, every patient. Aggregate everything into a single CSV.
  2. Last file wins. Each upload replaces the previous cohort entirely.
  3. Pickup is scheduled. The vendor polls the folder on a fixed cadence.

Each rule is reasonable on its own. Together they produce a system with no error channel.

Failure mode 1: full replace is a loaded gun

“Last file wins” means the cohort is a pure function of the most recent upload. That has consequences:

The root problem: the protocol has no way to express intent. “Remove this patient” and “I forgot this patient” are byte-identical.

Failure mode 2: identity by name + DOB

When the MRN is missing, the vendor falls back to matching on first name, last name and date of birth. That’s a deterministic match on three fields that are:

Even the MRN has a trap: an MRN is only unique within the system that issued it. A CSV column called mrn with no namespace is ambiguous the moment you have two EHRs. FHIR solves this with Identifier.system + Identifier.value; a CSV has no place to say which system issued the number.

Failure mode 3: no acknowledgement

You put the file. SFTP returns success. That’s the last thing you know.

In practice teams compensate with email threads (“did you get Tuesday’s file?”) and a support ticket per incident. That’s a protocol, just a bad one.

Failure mode 4: no deltas, no audit

Because each upload is a full snapshot, the change — who joined, who left, when — is not data anyone sends. If you need it (and you do: attribution changes drive billing, outreach and quality denominators), you have to diff snapshots yourself:

import csv

def load(path: str) -> dict[str, dict]:
    with open(path, newline="") as f:
        return {row["mrn"]: row for row in csv.DictReader(f)}

prev = load("roster_2026-09.csv")
curr = load("roster_2026-10.csv")

added   = curr.keys() - prev.keys()
removed = prev.keys() - curr.keys()
changed = {k for k in curr.keys() & prev.keys() if curr[k] != prev[k]}

print(f"+{len(added)}  -{len(removed)}  ~{len(changed)}")

That works only if you kept both files, only if the MRN is populated and stable, and it tells you nothing about why someone was removed. The vendor runs the same diff on their side with their own logic, and the two diffs disagree at month-end reconciliation.

Failure mode 5: the security model is a key file on a laptop

SSH key authentication is fine cryptography. The operational reality is less fine:

What “better” needs to look like

Flip each failure mode and you get the requirements for the rest of this series:

FailureRequirementCovered in
Full replaceExplicit add/remove operations; inactive instead of deletedPart 3
Fuzzy identityNamespaced identifiers; structured patient resourcesPart 2
No acknowledgementSynchronous responses with row-level outcomes; async jobs with statusParts 3–4
No deltas/auditChanges as first-class data; _since queriesParts 3–4
Static keysShort-lived, scoped tokens from signed assertionsPart 5
PollingPush notifications when the list changesPart 6
Big-bang cutoverDual-run and reconcilePart 7

None of this needs inventing. HL7’s Da Vinci Member Attribution (ATR) List implementation guide already defines a FHIR-based model and API for exactly this exchange — and CMS uses the same pattern in production for ACO beneficiary lists in the Beneficiary Claims Data API (BCDA). That’s the target architecture.

The code in this series is Python + FastAPI, with synthetic patients only (Synthea). No real rosters, no vendor-specific schemas.

Next: Part 2 — Modeling a roster as a FHIR Group

References


25 Oct 2026 · #healthcare #fhir #dataengineering #rosterapiseries

GLB_


Share this post:

Related Posts


Previous Post
Modeling a patient roster as a FHIR Group (Da Vinci ATR)
Next Post
Liquidity pools vs lending protocols: what you're actually selling