Email automation
Postmortem email-agent: 184 condoleances naar verkeerd gezin
Maandag 09:14. De email-agent van een Gronings uitvaartbedrijf had 184 condoleances klaarstaan, elk met de verkeerde nabestaande. Dit ging er stuk en zo vingen we het op.

09:14 op een maandagochtend. De nachtelijke queue van een Gronings uitvaartbedrijf met 23 medewerkers was in het weekend opgelopen tot 184 conceptcondoleances. De agent die wij schreven verwerkt de intake vanaf de tablet van de uitvaartondernemer, kiest het juiste sjabloon (katholiek, hervormd, seculier, twee streektalen), vult de mergevelden, en zet mails klaar voor de familiebegeleider die ze om 09:30 vrijgeeft.
De begeleider opende het eerste concept. De overledene in de header was mevrouw V., zaterdagavond overleden. De tekst in de brief sprak over meneer K., die in maart was begraven. De condoleances waren gericht aan de kinderen van V. De uitvaartregelingen, de kerk, de catering — allemaal die van K.
Ze opende het tweede concept. Dezelfde V. in de header. Dezelfde K. in de tekst. Bij het zevende concept trok ze de SMTP-stekker uit haar bureau en rende naar de IT-kast. Geen van de 184 mails was verstuurd, omdat vrijgave op 09:30 stond, niet op 09:00. We hadden zestien minuten.
Wat er daadwerkelijk in de queue stond
Het werk van de agent is overzichtelijk. Hij haalt een nieuw dossier uit Plotbox (een platform voor begraafplaats- en crematoriumbeheer dat breed wordt gebruikt in de Benelux), koppelt dat aan het contactblad van de familie, kiest een van veertien sjablonen (katholiek, hervormd, seculier, met regionale dialectvarianten voor Drents, Gronings en standaard Nederlands), vult de mergevelden, en zet een concept klaar in de outbox van de begeleider. Een mens geeft altijd vrij. De regel "nooit automatisch een condoleance versturen" is in dit domein niet onderhandelbaar — zo hebben we het op dag één gebouwd.
De storing zat upstream van de agent. In Plotbox is de primary key op een dossier dossier_id, een integer die per tenant automatisch oploopt. Toen dit bedrijf eind 2024 fuseerde met een kleinere onderneming, mapte het migratiescript de dossier-ids van de overgenomen partij door ze te offsetten — meestal met +500000 — zodat er geen botsingen konden ontstaan op de hoofdtabel. De migratie was schoon in de zin dat geen rij verloren ging. Hij was onschoon in de zin dat de offset niet op iedere secundaire referentietabel werd toegepast. Een handvol rijen in de audit-log wees nog naar de niet-geoffsete id, en een back-office cleanup zes maanden later "heelde" die rijen stilletjes door de niet-geoffsete id terug te schrijven naar de hoofd-dossiertabel. Dus dossier_id = 41822 bestond aan beide kanten, en na de cleanup opnieuw.
In het weekend opende een back-office gebruiker het oude record opnieuw om een openstaande factuur uit 2024 af te wikkelen. Plotbox draaide de soft-delete terug. De nachtelijke sync van onze agent, die "alle dossiers met activiteit sinds de laatste poll" ophaalt, pakte 41822 gewoon mee. De activiteit zat op de casus uit 2024. Het nieuwe dossier dat zaterdagavond werd aangemaakt droeg ook 41822. De renderer van de mergevelden vroeg "welk dossier is 41822" en kreeg in 7 van de 10 lookups de oudere terug — Plotbox geeft records terug op volgorde van update, en de oudere rij was net aangeraakt.
De sjabloon-renderer merkte niet dat hij twee mensen aan elkaar plakte. {{ deceased.name }} resolvete tegen de nieuwe casus. {{ service.location }} resolvete tegen de oude. De agent zag een volledig ingevuld concept en zette het in de queue. Doe dat 184 keer, één keer per nabestaande in het actieve adresboek, en je hebt maandagochtend.
Waarom de agent een verouderde foreign key vertrouwde
Twee aannames faalden tegelijk. De eerste: dat de primary key van het CRM uniek was over tijd. Op papier wel. In de praktijk niet, omdat de fusie-import dataretentie boven sleuteluniciteit stelde, en een latere cleanup de offset terugdraaide die hem beschermde. De tweede: dat onze sjabloon-renderer luid zou falen als een merge field ambigu terugkwam. Dat deed hij niet, omdat de rendering library — Jinja2 gevoed door een dunne SQLAlchemy-adapter — "twee rijen voor één id" behandelt als "gebruik de eerste rij", de SQL-default die iedereen vergeet tot hij je bijt. We hadden een eval-suite die nachtelijks golden-template fixtures door de renderer haalde. Geen van de fixtures testte het geval waarin dezelfde id naar twee rijen resolvete. Waarom zouden ze? Het schema zei dat het niet kon.
Als je agent leest uit een CRM met een geschiedenis van fusie-imports, is de integer primary key een vreemd concept. Behandel hem als hint en leid identiteit opnieuw af uit de velden waaraan een mens de persoon zou herkennen.
De UUID-namespace gate per overledene
De oplossing is klein en bijna gênant om op te schrijven. Voordat een condoleance-mail de SMTP-tunnel ingaat, berekenen we een deterministische UUID v5 voor de overledene op basis van een door ons beheerde namespace UUID en de velden waaraan een mens een persoon in dit domein herkent: volledige juridische naam, geboortedatum, sterftedatum en de canonieke naam van de uitvaartlocatie. Vervolgens controleren we of dezelfde UUID voorkomt op (a) het dossier dat de agent ophaalde, (b) het contactblad, (c) de merge-context van het sjabloon, en (d) de ontvangerrij.
Als één van die vier UUID's afwijkt, wordt de mail vastgehouden, het dossier gemarkeerd en een mens gewaarschuwd. Geen uitzonderingen. Geen "soft" mode waarbij we het met een waarschuwing toch doorlaten.
De code ziet er zo uit:
import uuid
from dataclasses import dataclass
# Namespace UUID, generated once with `uuid.uuid4()` and committed to the repo.
# Rotating it is a breaking change. Treat it like a private key.
NS_DECEASED = uuid.UUID("8f1c5e2a-1d4a-4bd6-9c0b-6a8a3e9e7f10")
@dataclass(frozen=True)
class DeceasedIdentity:
legal_name: str # "Achternaam, Voornaam Tweede"
birth_date: str # ISO 8601, no time
death_date: str # ISO 8601, no time
service_location: str # canonical name from a controlled list
def uuid(self) -> uuid.UUID:
# lowercase + strip to avoid case/whitespace splits
key = "|".join([
self.legal_name.strip().lower(),
self.birth_date.strip(),
self.death_date.strip(),
self.service_location.strip().lower(),
])
return uuid.uuid5(NS_DECEASED, key)
class IdentityMismatch(Exception):
pass
def gate(dossier, contact, render_ctx, recipient) -> uuid.UUID:
ids = {
"dossier": dossier.identity().uuid(),
"contact": contact.identity().uuid(),
"render": render_ctx.identity().uuid(),
"recipient": recipient.identity().uuid(),
}
if len(set(ids.values())) != 1:
raise IdentityMismatch(ids)
return next(iter(ids.values()))
De exception houdt de mail vast en post de vier UUID's, plus de bronvelden waaruit ze zijn afgeleid, in het on-call kanaal. De begeleider ziet binnen tien seconden welke twee records aan elkaar geplakt worden en welke twee kloppen. Ze hoeft geen 184 concepten door te lezen om die ene foute regel te vinden.
Waarom naam plus data wint van een CRM-id
Een scepticus leest bovenstaande code en vraagt: waarom vertrouw je niet gewoon de GUID van Plotbox? Plotbox geeft inderdaad een UUID per record uit. We gebruiken hem niet. De reden is dat de GUID de rij identificeert, niet de overledene. De fusie-import maakte nieuwe rijen voor casussen die al bestonden, met nieuwe GUID's, en wees oude foreign keys naar die nieuwe rijen. De identiteit van de rij verhuisde. Die van de persoon niet.
Door de vier velden te hashen die een uitvaartondernemer zou gebruiken om te bevestigen "ja, dit is de persoon die we begraven", krijgen we een identifier die elke CRM-interne herinrichting overleeft. Als dezelfde persoon in twee systemen met verschillende rij-id's voorkomt, is de UUID hetzelfde. Als twee verschillende personen per ongeluk in dezelfde rij belanden, is de UUID anders en springt de gate aan. De afweging: verandert een veld in de echte wereld — een verkeerd gespelde achternaam wordt gecorrigeerd, een uitvaartlocatie verhuist tussen locaties — dan verandert de UUID mee. Dat accepteren we. Een vastgehouden concept op de dag dat een typo wordt hersteld is goedkoop; een verkeerd verzonden condoleance niet.
De keuze voor UUID v5, in plaats van v4 of een platte SHA-256, is bewust. UUID v5 is gespecificeerd in RFC 4122 §4.3, is deterministisch gegeven een namespace en een naam, en elke database die we aanraken — Postgres, de Plotbox-export, de SMTP audit-log — heeft al een native UUID column type. De gate sluit aan op het bestaande schema zonder één migratie aan de CRM-kant.
De SMTP-tunnel dichtzetten
De gate draait in hetzelfde proces dat de uitgaande mail signeert en in de queue zet. De SMTP-relay (postfix voor een transactionele provider) weigert elke envelope die geen header met de naam X-ABN-Deceased-Id draagt. Die header is de UUID die de gate teruggaf. Er is geen pad van agent naar wire dat hem omzeilt.
De header wordt ook in de body van het bericht geschreven als onzichtbare HTML-comment, zodat als een concept later wordt doorgestuurd, geëxporteerd of geprint en opnieuw gescand, de identiteit behouden blijft. We houden 90 dagen rolling log van (header-UUID, ontvanger-hash, sjabloonversie) voor audit. Berichtinhoud bewaren we niet — er is geen operationele reden voor ABN om die vast te houden, en de mail van een uitvaartbedrijf hoort tot de gevoeligste die een Nederlands bedrijf verwerkt.
Als de gate aanspringt, krijgt het on-call kanaal een gestructureerd Slack-bericht: de vier UUID's in een diff-vriendelijk formaat, de bronvelden die verschillen, en een deep link naar het dossier in Plotbox. De standaardprocedure van de begeleider bij een mismatch is de familie direct bellen in plaats van per mail te excuseren — het excuusmiddel dat het incident veroorzaakte is precies het verkeerde gereedschap om het te repareren. We pagen tijdens kantooruren en queuen 's nachts, nooit andersom. Sinds de gate live ging, hebben we een passieve monitor toegevoegd die elke dossier_id markeert die binnen negentig dagen door twee verschillende overledene-UUID's is aangeraakt. Hij is in veertien maanden niet aangegaan.
Wat dit incident zegt over agent-systemen
De meeste publieke teksten over agent-betrouwbaarheid focussen op retries, evals en observability. Die zaken doen ertoe. Maar de failure mode die we net doorliepen heeft niets met het model te maken. De agent deed zijn werk correct gezien de inputs. De inputs waren twee mensen die door een database-mechanic van achttien maanden eerder tot één identiteit aan elkaar waren geplakt.
Agent-betrouwbaarheid leeft, in onze ervaring met veertien agents in productie, op de grens tussen de agent en de systemen waaruit hij leest — niet binnenin het model. De vragen die je moet stellen voordat je een klantgerichte agent uitrolt zijn: wat is de identiteitseenheid in dit domein, wie beheert de sleutel, en wat gebeurt er als de sleutel verandert zonder dat de onderliggende entiteit verandert. Kun je die niet in één zin elk beantwoorden, dan doet de agent vroeg of laat precies wat de onze die maandag deed: een plausibel, netjes opgemaakt, volledig fout artefact produceren — op schaal.
Wat je in het komende uur kunt doen
Drie dingen, in deze volgorde. Eén: maak een lijst van elk CRM waaruit je agent leest en vraag aan de DBA wanneer de laatste bulk-import of fusie heeft plaatsgevonden en of de primary key daarbij is herwezen. Het antwoord is meestal "ja, een paar jaar geleden" en niemand herinnert zich de offset-tabel meer. Twee: schrijf op één papiertje de vier of vijf velden op waarmee een mens in jouw domein een klant, een casus, een patiënt of een order herkent. Dat zijn je namespace-inputs. Vraag het niet aan het engineering-team — vraag het aan de baliemedewerker die al jaren aan de telefoon identiteiten bevestigt. Drie: zet een gate op de uitgaande kant. De goedkoopste, meest gênante plek om een identiteits-mismatch te vangen is in de mailserver, tien milliseconden voordat de envelope op de lijn gaat. Een header-check op de relay toevoegen is een middagklus, en het bespaart je het soort maandagochtend dat we in Groningen hadden.
Toen we de email-agent voor het Groningse uitvaartbedrijf bouwden, was waar we tegenaan liepen precies dat stille CRM-key hergebruik hierboven; we losten het op met een UUID-gate per overledene aan de SMTP-rand, die identiteit opnieuw afleidt uit de vier velden die een uitvaartondernemer toch al op een papieren checklist zou noteren. Het meeste van wat we leveren als AI-agent werk ziet er zo uit: agent in het midden, deterministische gate aan de grens, mens geeft vrij.
Kern
Behandel de primary key van je CRM als hint, niet als identiteit. Leid identiteit opnieuw af uit de velden waarmee een mens iemand herkent, en zet daar je gate op aan de uitgaande kant.
FAQ
Waarom UUID v5 in plaats van UUID v4?
v5 is deterministisch vanuit een namespace en een naam, dus dezelfde persoon levert overal in de pipeline dezelfde UUID op. v4 is random en geeft je geen manier om identiteit tussen systemen te vergelijken.
Waarom niet gewoon de eigen GUID van het CRM vertrouwen?
Een GUID identificeert een rij. Fusies en re-imports maken nieuwe rijen voor dezelfde persoon, met nieuwe GUID's. De identiteit van de persoon moet de identiteit van de rij overleven, dus leiden we hem af uit menselijk leesbare velden.
Wat als de uitvaartlocatie nog niet is ingevuld?
Dan wordt de mail vastgehouden. Uitvaartlocatie is een verplichte namespace-input; is hij leeg, dan weigert de gate vrij te geven en wordt een mens gepaged. Liever een vastgehouden concept dan een verkeerd geadresseerde condoleance.
Had een slimmer model dit kunnen vangen?
Nee. Het model kreeg een volledig ingevuld, intern consistent concept binnen. De mismatch zat tussen records, niet binnen één record. Deterministische gates winnen het van prompts voor identiteitschecks.
Vertraagt dit de uitgaande pipeline?
De gate kost ongeveer een milliseconde per mail. De hash is goedkoop en de vier lookups staan al in het geheugen tegen de tijd dat het concept wordt klaargezet. De SMTP relay-latency domineert met drie ordes van grootte.