Part 10 ended on an admission. The pipeline ran on its own, retried, resumed and recorded who asked, and it still could not tell me whether any number it produced meant anything.
Two parts had built a measuring instrument and had nothing to point it at.
Part 6 shipped a confidence-calibration harness, sixty-three labelled pairs and one command. It never ran — no API key in the environment it was built in. The report in the repository said Status: not yet run for seven weeks, and the gate shipped with no auto-approve band at all.
Part 8 shipped a Fellegi-Sunter scorer and an estimation harness. That one ran. It found nine people, zero true-match pairs against a floor of five hundred, and refused to publish a weight set.
Both refusals were correct. Both left the same hole: machinery that works, and no evidence it works on anything.
This part was supposed to close the hole by adding volume. What it did instead was find out why the volume would not have helped — and then, underneath that, why nothing in this platform had ever been matched to anything.
By the end of it the platform answers the question it is named for, which it could not do last week. Getting there cost four findings, and three of them are embarrassing.
What the Estimator Was Looking For
The weight estimator needs a positive class: pairs of records that are definitely the same person, so it can ask how often each field agrees when the answer is yes.
It takes them from one place, and the restriction is deliberate. A pair qualifies when two person rows hold the same exact identifier — the same payer, the same identifier type, the same value. That is a known same-person pair with no inference in it. The alternative, training on links the matcher itself produced, is circular in a way that hides: the weights would simply agree with themselves a little more every run.
So the whole positive class rests on one event: the same member arriving twice.
Five hundred of them, before the harness will publish anything. The floor is not arbitrary — at five hundred pairs the confidence interval on an agreement rate near 0.95 is about two points wide, which is the coarsest estimate a merge threshold can defensibly rest on.
Four facts decide whether that event can be recorded. Each one is correct on its own.
Every mapped member becomes a new person row. The write path inserts, with a fresh identifier and no lookup first. That is the design: a member who arrives twice is two rows, and linking them is the resolver's job.
An identifier is unique on the assertion. The constraint covers the payer, the type, the value and the start date, with a comment in the migration explaining that a re-issue earns its own row rather than overwriting the old one.
The start date is the earliest coverage begin date in the file. For a member enrolled on the first of January, that is the first of January, in every file that ever reports them.
The canonical write is one transaction per transaction set. Every member inside one ST–SE commits together or not at all, which is the guarantee the whole of Part 10 was built to preserve.
Read those four in sequence. A payer resends a member whose coverage has not changed. Same identifier, same start date. The insert violates the constraint. The transaction aborts — and it takes every other member in the set down with it.
A monthly enrollment file fails in month two, and the member who caused it is not the one you lose.
Nine people was never a small database. It was a database that could not get larger in the one direction the instrument was pointed.
A Corpus at the Shape Volume Has
To find out whether that was really the reason, I needed an enrollment year.
Not real data. There is no public enrollment corpus that labels which records are the same person, and that label is the entire point — without ground truth, a run cannot tell a correct answer from a confident one. Synthea and the CMS synthetic claims files were both on the original plan for this part, and both were dropped for the same reason.
So the corpus is generated, and the generator is the artifact that gets committed. One seed, three payers, twelve monthly files each: thirty-six files, a hundred and eight transaction sets, 5,217 synthetic people in 2,060 households. Subscribers carry their own contract identifier, dependents carry the subscriber's, and one payer also issues every member an id of their own — because that difference between trading partners turns out to matter later.
About a third of households get resent in a later month, the way a payer resends members it has already reported. 1,847 re-arrivals, of which 1,008 carry an identifier of their own and could therefore form a pair.
Every resend carries a named error class, recorded in a manifest beside the files:
| Class | Applied to | What changes |
|---|---|---|
clean | 804 | nothing — the commonest real case |
middle_initial | 359 | middle name in one arrival, absent in the other |
ssn_keying | 234 | last two digits of the SSN swapped |
nickname | 152 | Robert → Bob, Margaret → Peggy |
dob_transposition | 137 | 1974-03-12 → 1974-03-21 |
surname_change | 137 | a different surname, same date of birth and SSN |
suffix_dropped | 60 | a second person |
hyphen_dropped | 24 | Devi-Prasad → Deviprasad |
The class is on the record because a corpus whose hard cases nobody can explain will quietly stop being hard the day the prompt changes, and a corpus of those flatters every future run into looking like progress.
suffix_dropped is the exception — a son whose record carries his father's demographics — first name, middle name, surname, date of birth, gender, the last four of the SSN, every field the scorer compares — under his own contract id. The manifest labels those pairs different. Any run that gets them right is a run to distrust.
And the resends carry the same coverage begin date as the first arrival. The generator does not move dates to slip past the constraint. Sixty of the eventual pairs do carry a later date, because re-enrollment after a gap is a real thing that happens, but shaping the corpus around a defect would have hidden the defect.
Nothing is inserted into the database directly. Every row came through the real route — parse, map, gate, write — because a loader that wrote rows itself would be measuring a population the platform cannot actually produce.
That is the precise error that produced a sixty-three pair corpus measuring agreement between a prompt and the person who wrote the prompt.
January Landed. Nothing Else Did.
The first run used the write path exactly as Part 10 shipped it.
Every file from February onward failed, and every one of them failed at its first transaction set. With a third of households being resent, every employer group's monthly set carried at least one member already on file. The set rolled back, and the writer — which processes sets in order and stops at the first exception — never attempted the rest of the file.
Of the 4,734 member records that did not land, 468 were repeats of someone already on file. The other 4,266 were collateral — new enrolments, never seen before, lost because they shared a transaction set with somebody who had been.
Zero pairs. Not for want of people: 2,330 is 260 times Part 8's nine. Even the sixty resends with a later begin date, which the constraint would have happily accepted, never landed, because each one sat in a set that some other member's repeat had already doomed.
A Payer Saying It Again Is Not an Error
Two different events arrive looking identical at the identifier level, and the fix depends entirely on telling them apart.
A re-issue is a new assertion with a later start date. Already handled, already gets its own row, and the migration comment says so.
A re-assertion is the same payer saying the same thing about the same member again. It carries no new information about identity and it must not fail the file.
The tempting fix is ON CONFLICT DO NOTHING, and it is wrong. It collapses both events into the same silence, and it would swallow a genuine re-issue on the day the dates happened to match. So the write path now checks: an identifier assertion already on file — same triple, same start date — skips the insert and writes a lineage row recording a reasserted event, naming the row it agreed with.
That needed migration 008. The lineage vocabulary has been closed and constrained since Part 9 at eight values, and the migration that closed it left instructions for this exact situation: if the data holds something outside the list, the list is wrong, so widen it and write down why. reasserted is the ninth.
Reusing validated would have avoided the migration and been the worse answer. A closed vocabulary whose values each mean two things is free text with extra steps, and the question how often does this payer resend unchanged members becomes unanswerable the moment its answer is filed under something else.
The same corpus, the same seed, a database built from empty:
All 1,008 trace back to a single person in the manifest. None of them joins two people.
The Fix Alone Produced Nothing
That is not the order it happened in, and the gap between those two paragraphs is the most useful thing this part found.
The plan predicted the re-assertion would "finally produce tier-1 pairs at volume." It produced none. The constraint is unique on the assertion rather than on the person, so one identifier with one start date can belong to exactly one person row. When the repeat is skipped, the second person row ends up holding no identifier at all — and a self-join looking for two rows that share an identifier still finds nothing.
The estimator's other source is links marked deterministic. There are none, and there have never been any, because the function that would write one is called from nowhere in the codebase. The deterministic matching tier — the cheap, certain, exact-identifier tier that is supposed to run before anything probabilistic gets a turn — has a resolver seam that Part 4 left empty and nobody has filled since.
So the estimator gained a third source: it reads the re-assertion lineage. Two person rows asserting one exact identifier is the same fact as two rows sharing an identifier row, with no inference in it, so the rule against training on inferred links survives intact.
What this says about the schema has to be said plainly, because it is the finding underneath the finding. A uniqueness rule on the assertion only makes sense if a re-arrival is resolved to its existing person before the write. The schema was written for a write path that deduplicates people. The write path deliberately refuses to, and says why in its own docstring.
Neither decision is wrong on its own. Together they meant the platform could not record a member twice — and the estimator was built to learn from exactly that.
Re-assertion makes the pair survivable. It does not make the two decisions coherent. That is now the first item on the carried-forward list, addressed to whoever builds the deterministic tier.
Reading the Weights
Both floors cleared. No refusals. A weight set produced, for the first time in the project.
It is worth very little, and the report says so in a table.
The agreement rates are the generator's error mix, read back. Surnames disagree on 104 of the 1,008 pairs: exactly the 88 surname_change plus the 16 hyphen_dropped. First names on 74, the nickname count. Dates of birth on 78, the transposition count. The SSN on 137, the keying count. The estimator recovered what was injected, to the pair.
That is strong evidence the machinery is correct, and no evidence whatever about real enrollment data, because the error rates were chosen by whoever wrote the generator's weights table. Part 6's hand corpus measured agreement between a prompt and its author. This measures agreement between an estimator and its generator. The difference is that this one admits it in the output.
The fields order mostly as the domain says. Gender carries the least, at one bit. Date of birth and the SSN carry twelve each, far more than the names at five and a half. But identifier overlap does not dominate the way it should, and the reason is in its own row: it agrees on 1,008 of 1,008 true pairs, because every true pair was selected by sharing an identifier. The selection bias the module warns about, at its purest. On the negative side it agrees 0 times in 4,305, which the harness cannot distinguish from one in ten thousand, so it clamps to the statistical bound. The resulting weight describes the size of the negative sample rather than the strength of the field.
Clamping pushes in the safe direction — it under-weights the strongest evidence available. It is reported rather than tuned.
One error class is invisible to the scorer. 196 pairs differ only in the middle name, and the comparison function does not compare middle names. They score as perfect agreements. Harmless here, because the difference sits in a field nobody reads. Worth knowing, because a corpus that only exercised fields the scorer reads would never have revealed that the scorer has a blind field.
The negative class is faintly contaminated, in the unsafe direction. 839 re-arrivals are dependents with no identifier of their own. They are true same-person pairs the estimator cannot label, so they sit in the pool the random sample draws from. Expected contribution to a five-thousand-pair sample: about a fifth of one pair. Negligible now, and the kind of negligible that stops being negligible as the sample grows, because each one is a same-person pair counted as evidence of a non-match.
What the Model Thinks It Knows
The calibration harness ran for the first time. 312 pairs, $6.20, about two cents each, plus thirty-eight cents for the ten-pair dry run that priced it.
It ran against a model that was chosen two days ago, for a reason that belongs in this article rather than in a commit message.
The first live call never reached the API. The anthropic dependency had never been pinned, had resolved to a 1.x release, and 1.x rejects the temperature argument the resolver was passing. The second call reached the API and came back 404: the model the project has shipped since Part 5 no longer exists for this account.
So the AI tier had not made a successful call in this environment for an unknown length of time, and every test of it had passed throughout, because every test of it was mocked. The dependency is pinned now, a test asserts against the real signature, and the default model moved — in its own commit, with the report recording the model id, a fingerprint of the prompt, and the date, because a reliability curve belongs to one model and one prompt and to nothing else.
Then the numbers.
| Corpus | Pairs | Correct | False merges | Uncertain |
|---|---|---|---|---|
| hand-written | 63 | 57% | 3 | 24 |
| generated | 249 | 79% | 25 | 28 |
The suffix pairs did exactly what they were built to do. All twenty-five sons carrying their fathers' records were merged, at confidence 0.85 to 0.93. Nothing in the data distinguishes them, so merging them is the correct behaviour of a resolver reading those fields — and the exact reason no confidence band can be trusted to merge unattended on demographics alone.
Identical records cap at 0.93. Punctuation variants reach 0.97. The clean resends — the same member, the same record, twice — top out at 0.93, the same ceiling as the father-and-son pairs. What crosses 0.95, in both corpora, is a dropped hyphen or a stripped apostrophe. A pair that differs in a way the model can narrate earns more confidence than a pair with nothing to narrate.
Whatever the cause, the consequence is concrete: a threshold at 0.95 would merge a dropped hyphen and refuse a member sent twice unchanged.
The two corpora disagree, and the disagreement is the result. The generated corpus produced a top band that cleared every rule the plan set in advance — sixty-four pairs, all correct, zero false merges. The hand-written corpus could not put more than five pairs in that band.
The plan had predicted that a sharp disagreement would mean the small hand-written corpus was measuring its author. It is closer to the reverse. The generated band qualified on easy work: 45 of its 64 are confident no match answers about household members and name collisions, and only 19 are the match decisions an auto-approve rule would ever act on. The corpus that was hard on purpose is the one that refuses.
Which exposed something about the harness itself. It bands every decision together, so confident refusals count toward a band whose only use is approving merges. Qualification should be computed over the decision it would automate. That is carried forward rather than fixed, because changing the scoring rule after seeing the numbers is the move this entire part was built to refuse.
The Tier Nobody Switched On
What the measuring was for is the part of this I would rather have found in Part 5 than in Part 11.
The platform has three matching tiers. Tier 3 is the agent. Tier 2 is the Fellegi-Sunter scorer. Tier 1 is an exact identifier match — the payer tells you who the member is, you look the identifier up, and if somebody already holds it you have your answer. No model, no score, no threshold, one indexed query.
resolve_person was written in Part 3. It answers exactly that question. For seven parts it was called from nowhere. It appears in six docstrings and in no execution path. Part 4 left a resolver seam in the mapper and wired nothing into it; Part 5 built the agent, Part 6 the gate, Part 8 the scorer. Seven parts of increasingly clever matching stacked on top of a tier that had never run.
That is why the estimator found nothing and it is why nothing in this platform had ever been linked to anything. Person rows accumulated and the one cheap thing that could have joined them was not plugged in.
So I wired it. One module: an exact identifier match is a fact, so it writes a link and skips tiers 2 and 3 for that member; an identifier two people hold is collected as a finding and never linked; everything else carries on to the demographic tiers. The link carries a NULL score, because a probability would imply the match could be wrong.
It produced zero links.
POST /ingest had been gating the whole resolution stage behind ?resolve=true since Part 5, and the reason is still written in the parameter description: it costs an API call per candidate pair. That was true when it was written. It was never true of tier 1, which costs a query. The cheapest tier in the platform spent six parts switched off behind the most expensive one's flag, and the corpus found it by posting files the way a partner would — without the flag.
Tier 1 now runs on every file. The flag buys the demographic tiers, which is all it was ever protecting anyone from.
Then 974 of 1,014 resends linked, and the 40 that did not were every re-issue in the corpus.
Migration 002 says a re-issue — the same identifier asserted with a later start date — gets its own row rather than overwriting the old one. Correct. Nothing closed the old row. So a reprinted card left two open active windows for one identifier, resolve_person correctly reported that two people held it, and the eligibility endpoint answered 409 for a member whose only crime was being sent a new card. The write path now closes the assertion a re-issue supersedes. Ambiguous identifiers: 40, then 0.
A re-issue still does not link, deliberately. CMS never reuses an MBI; payers recycle member ids. An identifier moving to a new assertion might be a new card or might be a different human, so tier 1 declines and leaves it to the tiers that weigh demographics.
Every link checked against the manifest. Zero wrong, zero untraceable, zero model calls. And the breakdown is the argument for having a tier 1 at all:
| The later arrival differed by | Linked anyway |
|---|---|
| nothing — a clean resend | 403 |
| a middle initial | 191 |
| two transposed SSN digits | 134 |
| a married surname | 85 |
| a transposed date of birth | 73 |
| Bob for Robert | 73 |
| a dropped hyphen | 15 |
Every row is a demographic difference, and the identifier is identical through all of them. 85 surname changes and 134 keying errors resolved with no score, no threshold and no API call — because the payer had already said who the member was, and for seven parts nobody was reading it.
Answering the Question
src/api/routes/eligibility.py contained one line of docstring. It said (Part 4+), and it had said that since the day the package was scaffolded.
Eleven parts shipped a parser, a canonical model, an agent, a rules engine, a probabilistic scorer, a bitemporal audit trail and an orchestrator — on top of an endpoint that did not exist. Is this person covered on this date is the question the entire platform is for, and it had no implementation.
It could not have been written before tier 1 was wired, and that is the honest reason rather than an excuse. A member sent three times is three person rows. Asking any one of them for coverage returns a third of the truth with no indication the rest exists. The endpoint needed a person graph before it could answer over a person instead of over a row.
Now:
GET /eligibility?assigning_authority=123456789&id_type=subscriber_id
&identifier_value=AC0000088&as_of=2026-07-01
{
"covered": true,
"spans": [
{ "plan_id": "ACME-PPO-GOLD", "benefit_status": "active",
"valid_from": "2026-01-01", "valid_to": "2027-01-01",
"asserted_by_person_ids": ["5e5414bb…", "bc512594…"] }
],
"evidence": { "rows_consulted": 2, "link_basis": "deterministic" }
}A real member out of a real 834, through parse, map, gate and write, resolved across two person rows by an exact identifier, answered as one enrollment with both asserting rows named. The identical second span collapses into the first, because a caller counting spans would otherwise read one enrollment as two.
Three decisions in there are worth more than the endpoint.
It walks deterministic links only. A wrong merge here denies a real claim or pays for a stranger's surgery. Tier 2 ships with no measured threshold and the agent merges father-and-son pairs at 0.93, so this endpoint refuses to depend on either — by construction, not by configuration.
A missing member is a 404, not covered: false. Those are different facts. One says the platform has never been told about this person; the other says it has, and they are not enrolled. Collapsing them makes a file that failed to arrive look exactly like a termination.
An identifier two people hold is a 409. The alternative is picking one of them and returning a confident benefits decision about the wrong human.
What Got Switched On, and What Did Not
Both measurement harnesses cleared their mechanical conditions and neither feature was enabled. That reads like the whole story and it is about a third of it, so here is the ledger plainly.
Switched on, and running on every file: tier 1, the exact identifier match, with 974 links on the corpus and not one of them wrong. The re-issue fix, which turned 40 false conflicts into 40 correctly-superseded assertions. GET /eligibility, which answers the question the platform is named for and did not exist last week. A dependency pin and a test against the real SDK signature, after finding that every live AI call had been failing. 1,784 tests, 29 of them new, mypy strict and ruff clean.
Deliberately left off: the probabilistic auto-link, because every agreement rate in the weight set is a number somebody chose when writing the generator. The agent auto-approve band, because the band that qualified was counting confident refusals rather than the merges a rule would act on. Both reasons are written next to the thresholds rather than only in the reports.
The distinction that matters is between a capability that has no evidence and a capability nobody connected. The first two of those are the honest kind of off. Tier 1 was the other kind, for seven parts, and finding it is worth more than either measurement I set out to take.
What This Cost to Learn
Running the thing found what testing the thing did not, for the fifth part running.
Part 9 found that a steward's merge could not be recorded. Part 10 found the pipeline living inside an HTTP request handler. This one found a dependency that had silently broken every live call in the AI tier behind a suite of passing tests, a flag that kept the cheapest matching tier switched off, a uniqueness rule that turned a reprinted insurance card into a data-quality incident, and a matching tier that had never been plugged in at all.
None of those are findable by reading. Every one of them needed a file, a database and a run.
The instruments were fine. What they were pointed at was a platform that had never once matched a member to themselves — and that turned out to be a function call nobody had made.
Part 12 is the work basket: the queue, worked by a person. Eleven parts have described a platform whose entire purpose is to decide what it can and hand the rest to a human, and not one of them has shown the human.
The queue is now honest, which it was not before. It holds 40 re-issues that tier 1 declines on purpose, the pairs the scorer would settle if anybody trusted its weights, and sixty father-and-son records that a confident model merged at 0.93. What it no longer holds is 974 members who were only ever in there because a function was never called.
This is Part 11 of Building the Platform, a build-in-public series on a healthcare enrollment data platform. The corpus, the people in it and the payers are synthetic; the generator is seeded and reproducible, and the ground-truth manifest is the only reason any of the numbers above can be checked.






