← Blog

Legacy sites

IDEXX Cornerstone-migratie: playbook voor dierenklinieken

Een on-prem Cornerstone-server zoemde in een kast achter de kattenafdeling in Leiden. Elf jaar patiëntdossiers, elke registratie van opiaten, één haperende UPS.

Jacob Molkenboer· Oprichter · A Brand New Company· 19 mrt 2025· 9 min
Open leren logboek op ivoren vloei, messing sleutel op kaart, ijzeren label met touw, groen lint, rood lakfragment.

De on-prem Cornerstone-server zoemde in een kast achter de kattenafdeling. Hij draaide op een Dell tower uit 2017 met een Sybase SQL Anywhere-database, in de kelder van een dierenkliniek in Leiden, en de UPS ervoor knipperde driemaal per minuut amber. Elf jaar patiëntdossiers stonden op die schijf. Net als elke registratie van gereguleerde middelen die de praktijk wettelijk moest bewaren.

De keten was gegroeid naar 22 medewerkers verdeeld over drie locaties. De leverancier was verder gegaan. De on-prem versie van IDEXX Cornerstone die de kliniek draaide was end-of-life op hun hardware, en de cloudversie paste niet bij de workflow waar ze tien jaar aan hadden geschaafd. Dus bouwden we ze een nieuwe.

Dit is het playbook. Gekleurd door eigen mening, opzettelijk saai, en het enige soort werk dat niet eindigt met een advocaat in de spreekkamer.

De randvoorwaarde die niemand opschreef

Een dierenkliniek is geen CRM. Het patiëntdossier is niet het belangrijkste op schijf. Dat is het medicatielogboek. Onder de Nederlandse diergeneeskundewet moet elke toediening en aflevering van een diergeneesmiddel op recept geregistreerd worden met datum, dier, eigenaar, product, batch, hoeveelheid en voorschrijver. Die registraties moeten een inspectie van de NVWA doorstaan voor minimaal vijf jaar terug, en ze mogen niet met terugwerkende kracht worden ingevoerd.

Cornerstone dwong dit impliciet af. De medicatiemodule was in de praktijk append-only, ook al stond de onderliggende SQL Anywhere-database technisch updates toe. De kliniek had nog nooit een registratie verloren. Het nieuwe systeem moest aantoonbaar veiliger zijn, niet minder.

Voordat we ook maar één regel code aanraakten, schreven we één zin op een whiteboard: "het medicatielogboek is de ruggengraat; al het andere hangt eraan." Elke architectonische keuze in dit verhaal komt terug op die zin.

Wat we eigenlijk aan het vervangen waren

De voetafdruk was groter dan de inkoopsheet suggereerde. Cornerstone was niet één applicatie. Het waren patiënt- en klantdossiers met huishoudens met meerdere huisdieren en gekoppelde verzekeringsfacturatie. Een agenda die was verbonden met drie kalenders en een papieren back-up bij elke balie. Visitenotities inclusief SOAP-formulieren, gewichten en gestructureerde vaccinatieregistraties. Het medicatielogboek, plus integratie met het gedrukte logboek van de opiatenkast. Binnenkomende laboratoriumuitslagen via een lokale IDEXX VetLab-integratie. En facturatie, met een eigenaardige pro rata verzekeringskorting die niemand kon uitleggen, maar die elke receptionist correct toepaste.

De doelstack was opzettelijk klein. Een Next.js front-end, een Supabase-project (Postgres, Auth, Storage, Edge Functions), en een voice-agent-laag over Twilio voor triage buiten kantooruren. Geen Kubernetes. Geen microservices. Twee schema's in het Supabase-project: één live applicatieschema, één append-only audit-schema met een eigen rol.

De architectuur, klein getekend

Drie dingen verplaatsen data in dit systeem: de medewerkers-app, de IDEXX-lab-webhook en de voice-agent. Ze schrijven allemaal naar dezelfde Postgres-database, maar onder verschillende rollen, en slechts één van hen mag het medicatielogboek aanraken.

[staff browser]  --->  Next.js (Vercel)   --->  Supabase Postgres
[IDEXX VetLab]   --->  Edge Function       --->  (live schema)
[Twilio call]    --->  Edge Function       --->  voice_request queue
                                                  |
                                                  v
                                            audit schema
                                            (append-only,
                                             hash-chained)

De splitsing tussen de schema's is belangrijk. Het live schema is normaal: patiënten, eigenaren, visites, facturen. Het wordt bijgewerkt, gecorrigeerd, soft-deleted. Het audit-schema is paranoïde: rijen worden ingevoegd door één enkele trigger, nooit geüpdatet, nooit verwijderd, en elke rij draagt een SHA-256-hash van de vorige rij plus zijn eigen inhoud. Mocht iemand rij 401 manipuleren, dan klopt de hash op rij 402 niet meer, en de dagelijkse reconciliatie-job merkt het binnen uren op.

Het medicatielogboek

Dit is het deel waar de inspecteur naar gaat kijken, dus was het ook het eerste wat we bouwden. De logboektabel ziet er ongeveer zo uit:

create schema audit;

create table audit.medication_log (
  id            bigserial primary key,
  recorded_at   timestamptz not null default now(),
  patient_id    uuid not null,
  product_code  text not null,
  batch         text not null,
  quantity_ml   numeric(8,2) not null,
  prescriber    text not null,
  reason        text not null,
  prev_hash     bytea,
  row_hash      bytea not null
);

revoke update, delete on audit.medication_log from public, app_user;
grant insert, select on audit.medication_log to app_user;

De hash wordt berekend in een BEFORE INSERT-trigger. Hij plakt de canonieke JSON van de velden van de nieuwe rij aan de row_hash van de vorige rij (gesorteerd op id) en draait er SHA-256 overheen. Een nachtelijke job loopt de keten opnieuw door en schrijft een ondertekend dagelijks anker weg in een aparte tabel. We verwijderen nooit een foute registratie; we voegen een correctieregel in met als reden "correctie van rij 1402, zie chartnotitie 1408", en de inspecteur ziet beide.

Als je dit nog nooit hebt geschreven, geven de Postgres-documentatie over CREATE TRIGGER en de Supabase-handleiding voor row-level security samen alles wat je nodig hebt. De trigger doet de chaining. RLS zorgt ervoor dat de verkeerde rol überhaupt niet kan schrijven.

Waarschuwing

Laat je ORM niet in de buurt van deze tabel. Wij gebruiken één handgeschreven stored procedure om vanuit applicatiecode in het medicatielogboek te schrijven. Iets ingewikkelders en een junior developer schrijft uiteindelijk een migratie die een kolom dropt "om het op te schonen".

Elf jaar dossiers, verhuisd

SQL Anywhere is niet zo exotisch als het lijkt. We koppelden aan de Cornerstone-export via ODBC vanuit een klein Python-script dat draaide op een laptop op het kliniek-LAN, dumpten elke tabel naar CSV met een stabiele sortering, en draaiden hetzelfde script drie nachten achter elkaar om te bevestigen dat de rijaantallen stabiel waren. Dat waren ze niet, de eerste nacht. Cornerstone had een zelfreinigende job op de audittabel die om 03:14 draaide. We hebben hem verzet en de dumps opnieuw gedraaid.

Het moeilijke was niet de dump. Het was de dedup. Elf jaar lang hadden receptionisten dezelfde hond drie keer aangemaakt: "Bobby Jansen", "Bobby J.", "Bobby (Jansen)". We hebben niet automatisch gemerged. We bouwden een kleine review-UI die twee kandidaatrecords naast elkaar zette met hun visitegeschiedenis, en een dierenartsassistent klikte er vier ochtenden doorheen. We logden de mergebesluiten naar een vierde tabel zodat we ze later konden terugdraaien. Drie ervan zijn we daadwerkelijk teruggedraaid.

Röntgenfoto's en echo-stills stonden in een gedeelde SMB-map, niet in de database. We hashten elk bestand op inhoud, sloegen een kopie op in Supabase Storage met de hash als pad, en lieten de nieuwe patiëntdossiers naar de storage-URL wijzen. Duplicaten klapten vanzelf samen. De map kromp van 412 GB naar 188 GB.

De voice-agent die we niet vertrouwen

De kliniek wilde een triagelijn voor buiten kantooruren. Een baasje belt om 22:47, de receptionisten zijn thuis, en de dienstdoende dierenarts hoeft alleen voor echte spoedgevallen gewekt te worden. Dus bouwden we een voice-agent op Twilio plus een taalmodel, met één randvoorwaarde die we in de system prompt schreven en op databaseniveau afdwongen: de voice-agent mag geen enkel teken schrijven naar het patiëntdossier of het medicatielogboek.

Wat hij wel kan, is een rij invullen in voice_request, een queue-tabel in het live schema. De rij bevat de audio-opname, een transcript, een gestructureerde triagescore en de reden zoals de beller die zelf opgaf. 's Ochtends opent een dierenarts de queue, luistert naar wat de dienstdoende collega heeft gemarkeerd, en sluit het verzoek af of zet het door naar een visite. De doorzetting schrijft de chartregistratie onder het account van de dierenarts, niet onder dat van de agent.

Dit klinkt paranoïde. Dat is het ook. De recente reeks koppen over AI-agents die op hol slaan in productiesystemen van anderen is de beleefde versie van wat er gebeurt als je een taalmodel een gereguleerde registratie laat aanraken. In een dierenkliniek betekent "op hol slaan" een verkeerde doseringsregistratie die een inspecteur in februari leest. We hebben liever dat een vermoeide dierenarts 's ochtends een transcript leest, dan dat we ontdekken dat de agent autonoom Metacam heeft genoteerd bij een kat met een vastgelegde NSAID-allergie.

Cutover, met de stopwatch

De kliniek kon niet plat. Dieren plannen geen afspraak om. We draaiden de systemen veertien dagen dubbel. De Cornerstone-installatie bleef leidend. Elke handeling die het personeel in de nieuwe app deed, maakte ook een schaduwregistratie aan in Cornerstone via een kleine adapter die we tegen de export-API hadden geschreven. Aan het eind van elke dag vergeleek een reconciliatiescript de aantallen en signaleerde verschillen.

Er waren altijd verschillen. Het meeste had met tijdzones te maken (Cornerstone sloeg lokaal op, wij UTC). Een deel was echt, en het personeel ving twee bugs op die we anders mee in productie hadden genomen. Op de zaterdag van de cutover sloot de kliniek om 13:00, knipten we het verkeer om 13:30, en ging het medicatielogboek om 13:45 op het oude systeem read-only. We hadden papieren formulieren bij elke balie liggen voor het geval het nieuwe systeem zou uitvallen. Ze zijn niet gebruikt. We lieten de Cornerstone-server in de kast staan, uitgeschakeld, voor negentig dagen. Daarna maakten we een disk-image en stuurden de schijf naar de accountant van de kliniek voor cold storage.

Wat brak, en wat we leerden

Drie dingen braken in week één. De IDEXX VetLab-webhook begon een nieuw veld te sturen dat nergens gedocumenteerd stond, en onze Edge Function weigerde de payload tot we de schemacheck versoepelden. De bonprinter op de locatie in Voorschoten weigerde met iets anders dan Cornerstone te praten, omdat de driver hard-coded op een specifieke Windows COM-poort stond. We kochten een nieuwe printer voor €184 en gooiden de oude weg.

De derde was de duurste les. Een van de senior dierenartsen had Cornerstone-spiergeheugen ontwikkeld: Ctrl+Shift+M om de medicatiemodule te openen. Wij hadden hem niet gemapt. Ze verloor zes minuten per dag in de eerste week. We voegden de keymap op maandag van week twee toe. Map de oude sneltoetsen. Altijd.

Kernpunt

Een gereguleerde registratie is niet zomaar een databasetabel. Het is een append-only logboek met een hash-keten, één schrijver, een paranoïde rolgrens, en een inspecteur in je hoofd die het over vijf jaar zit te lezen.

De grens die het verschil maakte

Toen we deze herbouw voor de Leidse keten bouwden, was het deel waar we het meeste over hebben nagedacht niet de Next.js front-end of de voice-triage. Het was de grens tussen het live schema en het audit-schema, en die grens leveren we op elke legacy-migratie met een gereguleerde registratie in het hart.

Run je een kliniek, een apotheek of een ander bedrijf met een inspectiegevoelig logboek, doe vandaag dit. Open de relevante tabel in je huidige systeem, start een transactie, draai een UPDATE op een rij van vorig jaar, en daarna ROLLBACK. Als die UPDATE slaagde, is je audit trail een gewoonte, geen garantie. Die test van vijf minuten laat precies het gat zien dat een echte herbouw dichttrekt.

Kern

Een gereguleerde registratie is geen databasetabel. Het is een append-only logboek met een hash-keten, één schrijver, en een inspecteur in je hoofd die het over vijf jaar leest.

FAQ

Waarom niet gewoon upgraden naar Cornerstone Cloud en de workflow houden?

Geprobeerd. De cloudversie veranderde zoveel aan de afspraak- en medicatieflows dat de bijscholingskosten gelijk lagen aan een herbouw, en de data verliet de leverancier niet. Een herbouw gaf de kliniek eigenaarschap van het schema.

Hoe lang duurde de hele migratie, van start tot cutover?

Ongeveer veertien weken. Zes voor discovery en het audit-schema, vier voor de medewerkers-app en de lab-integratie, twee voor de voice-agent, en twee voor de dubbele run plus cutover. Het dedup-werk liep grotendeels parallel.

Wat gebeurt er met de medicatie-audit-keten als een rij gecorrigeerd moet worden?

We bewerken nooit. We voegen een correctieregel in die verwijst naar de id van de originele rij, met een geschreven reden. De hash-keten blijft intact, en een inspecteur ziet beide registraties naast elkaar.

Mag de voice-agent ooit naar het patiëntdossier schrijven?

Niet in deze build. Hij schrijft naar een verzoekqueue die een dierenarts handmatig doorzet. Misschien versoepelen we dat later voor niet-klinische velden, maar het medicatielogboek en de chartnotities blijven door mensen geschreven.

legacy sitesmigrationarchitecturevoice agentsai agentsoperations

Iets bouwen?

Start een project