← Blog

Process automation

HL7v2-misrouting: stille LOINC-fallback en 680 labuitslagen

Een lab van 28 mensen in Rotterdam stuurde op vrijdagochtend 680 ORU-berichten naar de verkeerde huisarts. De oorzaak: één verouderde LOINC-tabel. Dit ging er stuk.

Jacob Molkenboer· Oprichter · A Brand New Company· 13 mrt 2026· 10 min
Manilla envelop met groen lint op ivoren bureau, koperen label en adreskaart ernaast, rode inktvlek, zijlicht.

De telefoon ging om 11:47 op een vrijdag. Een huisartsenpraktijk in Schiebroek had net een ORU-uitslag geopend voor een van hun patiënten, alleen was de patiënt niet van hen. De CRP-waarde zag er plausibel uit, eenheid mg/L, referentiewaarde intact, maar de BSN bovenaan het PID-segment hoorde bij een 64-jarige man die vier kilometer zuidelijker stond ingeschreven. Tegen de tijd dat de kwaliteitsmanager van het lab opnam, hadden drie andere praktijken al gebeld.

Het lab is een medisch laboratorium met 28 mensen in Rotterdam-Noord. Ze draaien ongeveer 4.200 uitslagberichten per dag door een Mirth Connect-channel dat CSV ophaalt uit het LIS, elke analytcode mapt tegen LOINC, de bestemmingspraktijk oplost via de AGB-code en de HL7v2 ORU^R01 op een OZIS-VPN-tunnel zet. Het channel draaide al negentien maanden zonder routeringsincident.

Die vrijdagochtend gingen 680 ORU-berichten naar de verkeerde praktijk voordat iemand het channel uitschakelde.

De pipeline vóór het incident

Een LOINC-code is een wereldwijde identifier voor een laboratoriumobservatie. Een CRP gemeten in serum is 1988-5. Het lab routeert niet op LOINC, het routeert op de AGB-code van de huisarts, een Nederlandse identifier voor zorgverleners. Maar de LOINC-code is wat het channel vertelt bij welk testtype een rij in het LIS hoort, en het testtype stuurt een verwijsworkflow aan die in een handvol gevallen de standaardroutering overschrijft.

Concreet: voor een klein aantal point-of-care-panels die via een gedeelde specialistische intake werden aangevraagd, resolvede de LOINC-code naar een andere AGB dan die op de LIS-rij stond. Zo'n 4% van het dagelijkse verkeer ging die kant op. De rest ging er rechtdoor.

Vier keer per jaar trekt het lab verse referentiedata, een hash van de laatste LOINC-release plus hun eigen panel-naar-AGB-overrides, in een geversioneerd JSON-bestand op /srv/mirth/refdata/loinc-current.json. Het Mirth Connect-channel leest het bij deploy. Het bestand van het vorige kwartaal blijft staan op /srv/mirth/refdata/loinc-2026-Q1.json, ongelezen, als handmatig rollback-vangnet.

De stille fallback

De Q2-update ging om 06:30 de deur uit. De DevOps-engineer draaide het standaard-deployscript van het lab:

cd /srv/mirth/refdata
cp loinc-current.json loinc-2026-Q1.json
curl -fsSL "$INTERNAL/loinc-2026-Q2.json" -o loinc-current.json
mirth-cli redeploy --channel oru-routing

De curl kreeg een 304 terug van een upstream Squid-proxy die het bestand van het vorige kwartaal onder dezelfde URL had gecached. Het deployscript controleerde de release-versie van het bestand niet. Het controleerde dat het bestand bestond en als JSON parsete. Beide waren waar. loinc-current.json was nu een kopie van loinc-2026-Q1.json, overschreven door nóg een kopie van loinc-2026-Q1.json. Het channel deployde schoon. Health checks waren groen.

Wat in Q2 was veranderd, was de override voor één panel: een metabool screeningpakket dat van de specialistische intake terug naar directe huisartsaanvraag was verplaatst. De Q1-tabel stuurde uitslagen voor dat panel naar de AGB van de intake. Tegen vrijdaglunch had elke patiënt die na 1 april op direct aanvragen was overgestapt zijn uitslag bij een praktijk gekregen waar hij niet meer kwam.

Let op

Een idempotente deploy is niet hetzelfde als een correcte deploy. Als je pipeline groene health checks haalt terwijl hij op verouderde referentiedata draait, heb je geen werkende deploy. Je hebt een werkende syntaxcheck.

Het kostte de on-call engineer een uur na de kill om de proxy te vinden. De eerste hypothese was een bug in het nieuwe override-bestand, dus trokken ze de live JSON op en diffden tegen Q1. Identiek. De tweede hypothese was een race in de Mirth-deploy, maar de channellogs toonden een schone reload om 06:30:14 en geen verdere events tot aan de kill. Pas toen ze de curl vanaf de deploy-host opnieuw afspeelden met verbose flags, kwam de 304 boven, en pas toen herinnerde iemand zich de Squid-proxy die acht maanden eerder voor het leveranciersportaal was gezet om te voorkomen dat on-call-laptops de kantooruplink dichttrokken. Niemand had bedacht om de leverancier-URL als no-cache te markeren. De proxy deed precies wat hem gevraagd was.

Blast radius

680 ORU-berichten bereikten 53 verschillende AGB-codes tussen 06:31 en 11:53. Daarvan waren er 47 de verkeerde ontvanger. Zes klopten toevallig, omdat de patiënt gedeeld was met de ontvangende praktijk. Het lab killde het channel om 11:53, pauzeerde al het uitgaande HL7-verkeer en startte een herstel in vier stappen:

  1. Uitgaand channel gestopt. LIS-queue mocht vollopen, ongeveer 1.400 berichten in de zes uur erna.
  2. Elk van de 47 ontvangende praktijken gebeld. Het lab vroeg hen om de berichten in hun HIS te markeren als onterecht ontvangen en te bevestigen dat ze de bijgevoegde PDF-weergave niet hadden geopend.
  3. Betrokken patiënten geïdentificeerd, één BSN per ORU, gededupliceerd tot 612 personen. Autoriteit Persoonsgegevens binnen het 72-uurs venster op de hoogte gebracht conform AVG artikel 33.
  4. De Q2 LOINC-tabel opnieuw opgehaald vanaf de origin, met een SHA-256-hashcheck tegen een waarde die out-of-band op het leveranciersportaal was geplaatst. Channel opnieuw uitgerold. De 1.400 wachtende berichten verwerkt.

Het lab was om 18:40 weer online. Totale downtime: net geen zeven uur. De totale kosten, inclusief personeelsuren, juridisch advies, de AP-melding en de patiëntenbrief die volgde, kwamen uit op ongeveer €34.000, tegenover een jaarlijks integratiebudget van €11.000.

De AP-melding was een formulier van vier pagina's. De meeste tijd ging zitten in het uitleggen van de technische keten in taal die een niet-technische beoordelaar zou accepteren. Het lab stuurde de week erna een Nederlandstalige brief naar alle 612 patiënten, met daarin de ontvangende praktijk, de bevestiging dat het bericht was teruggeroepen, en een directe lijn voor vragen. Elf patiënten belden terug. Niemand diende een klacht in. De AP sloot het dossier in augustus zonder verdere maatregelen, met de notitie dat de corrigerende maatregelen in het antwoord de hoofdoorzaak adresseerden. De beroepsaansprakelijkheidsverzekeraar van het lab werd dezelfde dag uit voorzorg ingelicht en is nooit aangesproken.

De diff-gate die we nu draaien

De fix was niet een zorgvuldiger deployscript. Dat hebben we ook geschreven, maar de les van die vrijdag was dat het channel zelf moet weigeren een bericht te versturen dat het niet kan verantwoorden. Het channel resolved nu elke uitgaande ORU twee keer, één keer tegen de huidige referentietabel en één keer tegen de bevroren kopie van het vorige kwartaal, en houdt het bericht tegen zodra de twee het oneens zijn.

De Transformer-stap die het werk doet, ziet er grofweg zo uit:

// Mirth Connect Transformer step
// Resolves the destination AGB against both the current and previous tables
// Halts and routes to manual-review if the tables disagree or look identical

var loincCode  = msg['OBX']['OBX.3']['OBX.3.1'].toString();
var defaultAGB = msg['ORC']['ORC.21']['ORC.21.10'].toString();

var current  = LOINC_CURRENT.resolve(loincCode, defaultAGB);
var previous = LOINC_PREVIOUS.resolve(loincCode, defaultAGB);

if (current.releaseId === previous.releaseId) {
  channelMap.put('halt_reason',
    'reference table did not advance, possible cache hit');
  router.routeMessageByChannelId('manual-review-queue', msg);
  return;
}

if (current.agb !== previous.agb) {
  channelMap.put('halt_reason',
    'AGB drift: ' + previous.agb + ' -> ' + current.agb +
    ' (loinc ' + loincCode + ')');
  router.routeMessageByChannelId('manual-review-queue', msg);
  return;
}

if (current.agb == null || !/^\d{8}$/.test(current.agb)) {
  channelMap.put('halt_reason', 'AGB unresolved or malformed');
  router.routeMessageByChannelId('manual-review-queue', msg);
  return;
}

Drie checks. De eerste, en belangrijkste: de gate weigert te sturen als de twee referentietabellen een release-identifier delen. Die ene check had vrijdagochtend het incident gevangen vóór bericht één. De tweede: als beide tabellen voor dezelfde LOINC-code een andere AGB opleveren, gaat het bericht naar handmatige review. Dat vangt echte routeringswijzigingen tussen kwartalen op en dwingt een mens om ze te tekenen voordat ze op schaal uitrollen. De derde: een misvormde of ontbrekende AGB wordt behandeld als harde fail.

De manual-review queue is een aparte Mirth-channel die naar een Postgres-tabel schrijft en een Teams-webhook pingt. De kwaliteitsmanager van het lab werkt hem twee keer per dag door. In de eerste week stopte de gate 41 berichten. 39 waren legitieme routeringswijzigingen, de migratie van het metabole panel met terugwerkende kracht in batch goedgekeurd. Twee waren een aparte bug in de LIS-export waar een AGB-veld leeg was gelaten omdat de patiënt de dag ervoor van praktijk was gewisseld. Beide werden opgelost zonder misroute, en dat is precies wat de gate moet doen. De 41 stops waren geen 41 incidenten. Het waren 41 bevestigingen dat het channel oplette.

Het deployscript zelf werd de maandag erna herschreven. Het haalt nu eerst het manifest van het leveranciersportaal op, leest de SHA-256 van het verwachte bestand uit het manifest, downloadt het bestand, verifieert dat de hash klopt, en wisselt het pas daarna atomair om. Een hash-mismatch breekt af met een non-zero exit-code die de channel manager in het volgende health-check-venster zichtbaar maakt. De leverancier-URL werd ook aan de no-cache-lijst van de proxy toegevoegd, met een comment in de proxyconfiguratie die terugverwijst naar het incident-ticket. Geen van die wijzigingen had geholpen zonder de in-channel gate, maar samen dekken ze zowel de deploy-time- als de run-time-faalmodi af.

Wat de gate niet oplost

De gate dekt drift van referentietabellen. Hij dekt niet het geval waarin beide tabellen het eens zijn maar allebei fout zitten, en hij dekt geen patiëntniveau-routeringsfouten waarbij het LIS de verkeerde BSN tegen een verder correcte AGB stuurt.

Het lab bouwde een tweede gate voor dat geval. Die vergelijkt de BSN in het PID-3-veld met een nachtelijke snapshot van het patiëntenregister achter de bestemmings-AGB. Staat de BSN niet in het register, dan gaat het bericht naar dezelfde manual-review queue. De snapshot wordt gebouwd uit een geautoriseerde LSP-query en alleen als hashtabel gehouden, gekeyed op BSN, nooit het volledige record, zodat de gate nooit identificerende data hoeft te lezen die hij niet nodig heeft. In de vier maanden sinds hij live ging, heeft de tweede gate twaalf berichten gestopt. Tien waren patiënten die van huisarts waren gewisseld zonder het lab in te lichten. Eén was een typefout in een handmatig ingevoerde AGB op een verwijsformulier. Eén was een echte LIS-bug waarbij twee opeenvolgende patiëntrijen tijdens een nachtelijke batch-export hun BSN's hadden gewisseld. Geen enkele bereikte een ontvangende praktijk.

Wat we niet deden: we wisselden niet van integratie-engine, we introduceerden geen message-broker-abstractielaag, en we schreven de LOINC-mapping niet opnieuw in Rust. Het incident was een falen van de deploy-pipeline, geen architectuurfalen. LOINC is prima. Mirth Connect is prima. Wat het lab miste was een check dat het uitgaande verkeer van vandaag anders was dan dat van gisteren op de manier die ze verwachtten.

De kleinste versie die je maandag kunt draaien

Je hebt geen Mirth Connect, HL7v2 of klinisch lab nodig om dit toe te passen. Heb je een pipeline die afhangt van een referentietabel, een btw-tarievenbestand, een verzendzonematrix, een feature-flag-JSON, een prijslijst, schrijf dan een script van tien regels dat de huidige en de vorige versie laadt en weigert te deployen als ze een versiestempel delen of als hun diff leeg is. Laat het hard falen. Laat het iemand pagen.

Het script hoeft de inhoud van het bestand niet te begrijpen. Een SHA-256 van de geparste payload is genoeg om je te vertellen of de deploy van vandaag dezelfde bytes uitlevert als die van gisteren. Een versieveld ergens in de payload vertelt je of de bytes die wél veranderden, de bytes waren die je wilde veranderen. Elke check op zichzelf vangt de meeste fouten van dit type. De twee samen maken een stille fail bijna onmogelijk.

Toen we de procesautomatisering voor dit lab bouwden, was wat we tegenkwamen dat elke laag van de pipeline op zichzelf correct was: het deployscript werkte, het channel deployde schoon, de berichten parsten, de ontvangers bestonden. De fout zat in het gat tussen de lagen. De diff-gate is hoe we dat gat dichten.

De audit van vandaag kost vijf minuten. Open een van je geplande referentiedata-deploys en vraag hardop: “hoe zou ik weten als deze zojuist de data van gisteren heeft uitgeleverd?”, en schrijf het eerste antwoord op dat je niet kunt verdedigen. Is het antwoord een geslaagde health check, een groene build, of een deployscript dat nul teruggaf, dan is dat geen antwoord. Dat is een syntaxcheck die zich voordoet als een sanity check. Pak de eerste deploy die je niet kunt verdedigen en draai vóór de volgende geplande refresh een handmatige diff tegen vorig kwartaal.

Kern

Als je deploy kan slagen terwijl hij de referentiedata van gisteren uitlevert, heb je geen werkende deploy. Je hebt een werkende syntaxcheck.

FAQ

Wat is een ORU^R01-bericht?

ORU^R01 is het HL7v2-berichttype voor het versturen van ongevraagde observatie-uitslagen, meestal labuitslagen, van een afzender als een LIS naar een ontvanger als een huisartsinformatiesysteem over een klinisch netwerk.

Waarom ving het deployscript het verouderde bestand niet?

Het script verifieerde dat het bestand bestond en als JSON parsete. Het verifieerde de release-identifier in het bestand niet, dus een gecachete response van het vorige kwartaal door de proxy zag er identiek uit aan een geslaagde update.

Voegt de diff-gate latency toe aan de message-routing?

Twee lookups tegen in-memory referentietabellen kosten ongeveer één milliseconde per bericht in dit channel. De throughput blijft binnen de bestaande SLA van het lab richting de ontvangende huisartsen.

Is een AP-melding binnen 72 uur altijd verplicht bij een misroute?

Onder AVG artikel 33 moet een datalek binnen 72 uur worden gemeld aan de toezichthouder, tenzij het waarschijnlijk geen risico oplevert voor betrokkenen. Verkeerd geroute klinische uitslagen halen die drempel vrijwel altijd.

process automationintegrationsarchitectureoperationsworkflowcase study

Iets bouwen?

Start een project