Process automation
Payroll-incident: 1.840 loonstroken naar verkeerde DigiD
Incident-walkthrough: 1.840 loonstroken naar de verkeerde werknemers gestuurd nadat een vendor tenant-merge stilletjes GUIDs heruitgaf. Wat brak, en de gate die we nu draaien.

Het was een dinsdag in mei, 09:42 CEST. De controller van een loonbureau van 26 mensen in Tilburg opende haar support-inbox en zag negen mails binnen twee minuten, allemaal varianten op: "Ik heb net mijn loonstrook in MijnOverheid gedownload en het is de mijne niet." De payroll-automation agent van haar bureau had de run de avond ervoor gedraaid.
Om 09:48 hadden we het getal. De agent had de avond ervoor 1.840 loonstroken naar de verkeerde werknemer-DigiD-combinaties gestuurd, in één batch, via een schone SBR-inzending die een 200 OK terugkreeg en een ondertekende ontvangstbevestiging van de Belastingdienst. Niets in de pipeline had iets gemarkeerd. De Loonaangifte zelf klopte structureel; de totalen sloten tot op de cent; de XBRL voldeed aan de SBR-taxonomie. Alleen was hij op de verkeerde mensen gericht.
Dit is de uitwerking.
Het bureau, de agent, de stack
Het bureau verzorgt de payroll voor 312 mkb-werkgevers in Noord-Brabant. Hun stack voor ons was de gebruikelijke mid-market vorm: NMBRS als system of record, Twinfield voor het grootboek, Outlook voor de rest, en een Dropbox-map met de naam "DIT IS DE GOEDE" die niemand vertrouwde maar iedereen gebruikte.
Eind 2025 bouwden we voor hen een process-automation agent. Zijn werk was niet glamoureus: elke 27e van de maand de gesloten loonperiode uit NMBRS trekken via de bulk export API, per werknemer een PDF genereren uit het bedrijfssjabloon, die via de Berichtenbox-koppeling naar MijnOverheid sturen, en de Loonaangifte indienen via de SBR-tunnel. Daarna een regel wegschrijven in een Postgres-audittabel zodat de controller 's ochtends kon afstemmen.
Hij draaide al zeven maanden schoon. November tot en met april: 312 werkgevers, zo'n 4.200 loonstroken per maand, nul incidenten. Toen mei.
Wat de log om 22:11 zei
De run startte om 22:00 op de 27e. Om 22:11 had de SBR-inzending haar ondertekende ontvangstbevestiging teruggestuurd en was de agent verder. De Postgres-audittabel zag er voor de getroffen batch zo uit:
SELECT werkgever_id, count(*), min(employee_guid), max(employee_guid)
FROM payroll_dispatch_log
WHERE batch_id = '2026-05-27-T2200'
GROUP BY werkgever_id
ORDER BY count(*) DESC
LIMIT 5;
werkgever_id | count | min_guid | max_guid
--------------+---------+----------------+----------------
wg_004412 | 1840 | e1f3a... | 9c0d7...
wg_004413 | 94 | 3b421... | b8e90...
wg_004414 | 71 | c7f10... | d2a44...Niets schreeuwde. wg_004412 was een logistieke holding met veel magazijnpersoneel; 1.840 werknemers was ongebruikelijk maar plausibel. De aantallen kwamen overeen met wat NMBRS had teruggegeven.
Wat het verried, was één werknemer die mailde. Haar naam stond op haar loonstrook, maar haar BSN niet. Het BSN hoorde bij iemand bij een totaal andere werkgever, wg_001188, een school in Eindhoven. De PDF-rendering klopte. De MijnOverheid-routing niet.
De verouderde GUID
De NMBRS bulk export geeft per werknemer één regel terug met een stabiele EmployeeId (een GUID) die we gebruiken als join-key om de DigiD-koppeling van de burger op te zoeken in onze eigen mapping-tabel. Die tabel is de enige plek waar werknemer-GUID BSN wordt en BSN een MijnOverheid-mailbox.
De lookup was één functie:
def resolve_recipient(employee_guid: str) -> Recipient:
row = db.execute(
"SELECT bsn, mailbox_id FROM employee_digid_map WHERE employee_guid = %s",
(employee_guid,),
).fetchone()
if row is None:
raise UnmappedEmployee(employee_guid)
return Recipient(bsn=row.bsn, mailbox_id=row.mailbox_id)Die functie draaide 1.840 keer voor wg_004412 en gaf elke keer een regel terug. Geen exception. Geen log-waarschuwing. Elke lookup slaagde.
Het probleem zat dieper. In april 2024 had NMBRS twee van haar multitenant-clusters samengevoegd als onderdeel van een backend-consolidatie. Klanten kregen te horen dat de merge transparant was: dezelfde EmployeeId-waarden, dezelfde API-contracten. Voor 99,7% van de records klopte dat. Voor de rest, waaronder een batch soft-deleted werknemers uit een werkgever-overname in 2023, was de post-merge EmployeeId een heruitgifte van een GUID die eerder had toebehoord aan een andere werknemer bij een andere tenant.
De eigen documentatie van NMBRS noemt EmployeeId globally unique binnen de productieomgeving. Dat klopt, nu. Het klopte met terugwerkende kracht niet meer na de merge. Het bulk export endpoint geeft de canonieke huidige GUID terug. Onze mapping-tabel bevatte de pre-merge GUIDs van toen we het bureau in 2024 onboardden.
Dus toen de agent e1f3a... opzocht voor een werknemer bij wg_004412, kreeg hij een regel. Die regel wees naar een echt BSN met een echte MijnOverheid-mailbox. Dat BSN hoorde toevallig bij een leerkracht in Eindhoven van een loonbureau waar we nooit mee hadden gewerkt.
"Globally unique" in vendor-docs betekent bijna altijd "uniek binnen de live dataset, nu." Het betekent niet "uniek over alle data die de vendor je ooit heeft uitgegeven." Behandel GUIDs uit elk systeem dat ooit is samengevoegd, gemigreerd of gere-platformd als namespaced, niet als globaal.
Drie guards, alle drie stil
We hadden drie guards staan. Geen ervan ging af.
De eerste was een row-count guard: weiger de batch als len(dispatch) != len(nmbrs_export). Hij slaagde. De aantallen kwamen overeen.
De tweede was een BSN-formaat guard: elke ontvanger moet een BSN van 9 cijfers hebben dat door de elfproef komt. Hij slaagde. Elk BSN was geldig; ze hoorden alleen bij de verkeerde mensen.
De derde was sample-and-verify: na het renderen pakt de agent willekeurig vijf PDFs en vergelijkt het getoonde BSN met de metadata van de ontvanger-mailbox. Hier had het opgevangen moeten worden. De bug: het PDF-sjabloon rendert het BSN van de werknemer uit de NMBRS-payload, terwijl de mailbox-routing het BSN uit onze lokale mapping-tabel gebruikt. De vergelijking vergeleek de mapping-tabel met zichzelf. De PDF klopte niet, de metadata klopte niet, en ze klopten consistent samen niet.
Die laatste deed pijn. Het was een guard die we specifiek voor dit type bug hadden geschreven. Hij controleerde het verkeerde.
De 36 uur erna
Om 09:51 trokken we de agent eruit. Om 10:30 hadden we een lijst van elke getroffen loonstrook en de daadwerkelijk bedoelde ontvanger. Tegen het middaguur hadden we de berichten teruggetrokken bij MijnOverheid (Logius ondersteunt een recall-venster voor ongelezen Berichtenbox-berichten; de meeste operators weten dit niet, zie de MijnOverheid-integratiedocumentatie). Een klein deel was al geopend. Die handelden we telefonisch af, één voor één, met de controller van het bureau en een functionaris gegevensbescherming aan de lijn.
Het eerste telefoontje was het bureau naar zijn 26 getroffen werkgevers, voordat een van hen het van het eigen personeel hoorde. De controller schreef het script voor het gesprek van die ochtend: wat er was gebeurd, welke gegevens waren blootgesteld, wat we al hadden teruggetrokken, en wat elke werkgever naar zijn eigen werknemers moest communiceren. Drie van de 26 werkgevers regelden de interne communicatie zelf; de rest vroeg ons om een conceptbrief op te stellen die zij aanpasten en verstuurden. Tegen woensdagmiddag was elke getroffen werknemer bij elke werkgever benaderd door de eigen werkgever, niet door ons. Die volgorde deed meer voor het vertrouwen dan al het andere dat we die week deden.
We deden binnen het 72-uursvenster een datalekmelding bij de Autoriteit Persoonsgegevens. De melding noemde de getroffen datacategorieën (BSN, salaris, woonadres op sommige sjablonen), de populatie (1.840 werknemers bij één werkgever), en de containment-tijdlijn. De AP kwam terug met vragen over de herkomst van de mapping-tabel en het audit trail. We hadden beide, te danken aan het feit dat de Postgres-audittabel write-once en append-only is.
Uiteindelijk geen boete. Eén formele waarschuwing. Veel vertrouwen op te bouwen bij één specifiek bureau.
De andere 311 werkgevers doorlichten
In de nacht van de 27e, voordat we de structurele fix bouwden, deden we een inventarisatie op elke andere werkgever in de portefeuille van het bureau. De vraag die we voor zonsopkomst beantwoord moesten hebben: hoeveel rijen met verouderde GUIDs staan er in de mapping-tabel voor klanten waar we in mei nog niet naar hadden verstuurd?
We trokken de NMBRS bulk export opnieuw voor de overige 311 werkgevers en joinden hem terug aan employee_digid_map alleen op employee_guid, en vergeleken vervolgens het BSN dat wij in onze administratie hadden met het BSN dat NMBRS nu voor die GUID rapporteert:
SELECT m.werkgever_id, m.employee_guid,
m.bsn AS stored_bsn, e.bsn AS current_bsn
FROM employee_digid_map m
JOIN nmbrs_export_current e
ON e.employee_guid = m.employee_guid
WHERE e.bsn IS DISTINCT FROM m.bsn;De query gaf 47 rijen terug, verdeeld over 6 werkgevers. Geen ervan had in de mei-batch een verzending gehad (ze waren of soft-deleted aan de bureau-kant, of de werkgever had automatische verzending het vorige kwartaal uitgezet). Ze zouden in juni zijn afgevuurd. We bevroren het automatische dispatch-pad voor het hele bureau totdat het nieuwe schema live was en elke mapping-rij opnieuw was gesleuteld onder (werkgever_id, employee_guid). De bevriezing kostte het bureau vijf werkdagen handmatige loonstrook-PDF-generatie. Goedkoper dan het alternatief.
De fix: een GUID-namespace per werkgever
De structurele fix is één eigenschap die we nu afdwingen voordat een Loonaangifte-batch de SBR-tunnel verlaat. Elk werknemer-GUID moet, voordat het als lookup-key wordt gebruikt, gekwalificeerd zijn door zijn werkgever-id en gevalideerd worden tegen een namespace per werkgever.
In schema-termen veranderde de mapping-tabel van dit:
CREATE TABLE employee_digid_map (
employee_guid uuid PRIMARY KEY,
bsn char(9) NOT NULL,
mailbox_id text NOT NULL
);Naar dit:
CREATE TABLE employee_digid_map (
werkgever_id text NOT NULL,
employee_guid uuid NOT NULL,
bsn char(9) NOT NULL,
mailbox_id text NOT NULL,
source_export_id text NOT NULL, -- NMBRS export this row came from
first_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (werkgever_id, employee_guid)
);
CREATE UNIQUE INDEX employee_digid_map_bsn_per_werkgever
ON employee_digid_map (werkgever_id, bsn);De oude tabel terugvullen naar het nieuwe schema was het lastige deel. De verouderde tabel had helemaal geen werkgever_id-kolom, dus de juiste tenant per GUID moest worden gereconstrueerd uit de originele NMBRS-exportlogs. Die hadden we, gzipped, in cold storage staan: elke export sinds de onboarding in 2024 stond op schijf. We speelden ze oudste-eerst af, voegden ze in het nieuwe schema in, en vertrouwden de meest recente export per (werkgever_id, employee_guid)-paar als autoritatief. Waar we conflicten vonden (dezelfde GUID die over de historische registratie heen onder twee verschillende werkgevers verscheen), weigerde het nieuwe schema simpelweg de tweede insert en kreeg de rij een vlag voor handmatige review. Het ging om 47 zulke rijen. Ze kwamen exact overeen met de audit-query uit de nacht van de 27e.
De resolve-functie van de agent vereist nu het werkgever-id op de aanroeplocatie, en de SBR-inzendingsstap weigert te draaien als het (werkgever_id, employee_guid)-paar van een ontvanger niet in de huidige export-run is gezien:
def resolve_recipient(werkgever_id: str, employee_guid: str, export_id: str) -> Recipient:
row = db.execute(
"""
SELECT bsn, mailbox_id, source_export_id
FROM employee_digid_map
WHERE werkgever_id = %s AND employee_guid = %s
""",
(werkgever_id, employee_guid),
).fetchone()
if row is None:
raise UnmappedEmployee(werkgever_id, employee_guid)
if row.source_export_id != export_id:
raise StaleMapping(werkgever_id, employee_guid, row.source_export_id, export_id)
return Recipient(bsn=row.bsn, mailbox_id=row.mailbox_id)We schreven ook de sample-and-verify check opnieuw zodat hij leest uit een onafhankelijke bron, het NMBRS employee-detail endpoint, niet uit onze mapping-tabel. Hij markeert de batch nu bij meer dan nul mismatches, niet pas boven een drempelwaarde. Als de agent de tweede bron niet kan bereiken, stopt de batch.
We schreven NMBRS-support met de GUID-samples en een geredigeerde tijdlijn. Hun antwoord, drie werkdagen later, bevestigde de cluster-merge van april 2024 en het re-issue-patroon van soft-deleted records. Ze vlagden het voor het docs-team. Of de publieke API-referentie dit gedrag ooit gaat benoemen, is niet de inzet die we tweemaal willen doen; de lookup-gate hierboven draait of NMBRS zijn documentatie nu bijwerkt of niet.
Wat we nu geloven
Identity-keys uit vendor-APIs zijn namespaced op hun tenant, punt. We behandelen geen enkele GUID meer als globaal, ongeacht wat de docs zeggen, tenzij de vendor zijn re-use-beleid na merges, restores en undeletes specificeert. Het Microsoft Graph-team schrijft dit goed uit, zie hun deleted-items lifecycle docs, en het is een goede referentie voor hoe de vraag beantwoord hoort te worden.
Verificatie-reads moeten uit een ander systeem komen dan het systeem dat geverifieerd wordt. Als je geen tweede bron kunt krijgen, heb je niet geverifieerd.
Weigeren-te-draaien is goedkoper dan terugroepen. De agent stopt nu standaard bij elk onafgedekt geval (ongemapte GUID, verouderde export-ID, count-drift) in plaats van een waarschuwing te loggen en door te gaan. De controller van het bureau was het ermee eens: ze wordt liever om 22:11 gepiept om een batch goed te keuren dan 1.840 excuusmails te lezen om 09:42.
Wat je vandaag kunt doen
Open de agent, automation of het script in je stack dat identity raakt. Zoek de regel waar het een vendor-ID naar een persoon resolved. Vraag: op welke tenant is dit ID gescoped, en waar staat die tenant op deze regel? Als het antwoord "dat staat er niet" is, dan heb je dezelfde bug als wij hadden, en je hebt het nog niet gemerkt omdat nog niemand een tenant onder je heeft samengevoegd.
Toen we de process automation voor dit bureau bouwden, was wat ons opbrak het vertrouwen op de belofte "globally unique" van een vendor over een backend-migratie waar we niets van wisten. We hebben het opgelost met de namespace-gate hierboven, en we schrijven die gate nu op dag één in elke agent in.
Kern
Globally unique IDs van een vendor die ooit tenants heeft samengevoegd zijn niet globally unique. Geef elke lookup een tenant-namespace voordat hij live gaat.
FAQ
Waarom ving de SBR-inzending de verkeerde ontvangers niet op?
SBR valideert structuur, taxonomie en totalen tegen de Belastingdienst. Het weet niet of een BSN bij een bepaalde werkgever hoort. Een Loonaangifte kan structureel perfect zijn en toch aan de verkeerde persoon gericht.
Wat is een GUID-namespace per werkgever, in één zin?
Een regel die zegt dat elk werknemer-GUID van een vendor gekwalificeerd moet zijn door de werkgever waar het vandaan komt, en opgezocht moet worden via het paar (werkgever, GUID) in plaats van alleen de GUID.
Moeten we voor elk datalek zoals dit melden bij de AP?
Ja, de AVG-meldplicht vereist een melding binnen 72 uur als een lek waarschijnlijk een risico vormt voor de rechten van de betrokkenen. Loonstroken aan de verkeerde ontvanger met BSN en salaris vallen daaronder.
Hoe controleren we bestaande mapping-tabellen op verouderde vendor-GUIDs?
Trek de huidige vendor-export opnieuw, join hem terug aan je mapping-tabel op de GUID, en markeer elke rij waar de huidige werkgever van de vendor voor die GUID afwijkt van degene die je hebt opgeslagen. Die vlag is je werklijst.