Process automation
Douane-agent incident: 940 aangiftes onder verkeerde EORI
Op een donderdag in juni, om 09:14, vuurde de agent van een Almeerse douane-expediteur 940 invoeraangiftes af onder de EORI van een zustertenant. Dit is de post-mortem.

Op een donderdag in juni, om 09:14, zag de operations-lead van een logistieke expediteur uit Almere met 31 medewerkers 940 invoeraangiftes rood kleuren in haar dashboard. Stuk voor stuk afgewezen door DMS Inbreng om dezelfde reden: de EORI op de aangifte kwam niet overeen met de importeur op het cargo manifest. De expediteur draaide al veertien maanden aangiftes via onze automatiseringsagent. In tien minuten had de queue meer foute aangiftes verwerkt dan een menselijk team in een week zou indienen.
Om 09:23 belde ze ons. Om 09:51 stond de agent op pauze, hadden we de accountmanager van de Douane aan de lijn en lazen we de API-logs van het afgelopen kwartier mee. Dit is wat we vonden, wat we naar de Douane stuurden, en de gate die we sindsdien op elke douane-integratie zetten.
Wat tussen 09:02 en 09:14 in de queue belandde
De agent leest cargo manifests uit het TMS van de expediteur, normaliseert ze en dient de invoeraangifte in via DMS Inbreng. Op elke aangifte staat een EORI, het Europese douanenummer dat aangeeft welk bedrijf de goederen invoert. EORI's zijn geen routeringsdetail. Ze zijn de juridische identiteit op de aangifte. Indienen onder de verkeerde EORI is voor de douane het equivalent van iemand anders' naam onder een belastingaangifte zetten.
Tussen 09:02 en 09:14 diende de agent 940 aangiftes in. 916 daarvan droegen de EORI van een andere expediteur op hetzelfde tenant-cluster, een zustertenant die we zes weken eerder hadden onboarded. Vierentwintig aangiftes gingen wel goed, en juist die laten zien wanneer de bug startte. De laatste correcte aangifte had een request-timestamp van 09:02:11. Daarna wisselde de agent stilletjes van identiteit.
Het gecachete jurisdiction-token
De agent praat via een dunne interne wrapper met het AGS-achtige submission-endpoint. De wrapper draagt twee tokens: een klantauth-token dat bewijst dat de expediteur is wie hij zegt te zijn, en een jurisdiction-token dat indienen autoriseert voor een specifieke EORI onder een specifieke aangever. Het jurisdiction-token leeft kort, vijftien minuten, en de wrapper cachet het in Redis zodat het niet bij elke call opnieuw uitgegeven hoeft te worden.
De cache-key was jurisdiction:{declarant_id}. Dat had jurisdiction:{tenant_id}:{declarant_id} moeten zijn.
De zustertenant had een ander declarant_id, maar dezelfde wrapper-versie. Hun token werd die ochtend als eerste uitgegeven, op een toevallig koude cache. Om 09:02:17 vroeg de agent van onze expediteur een jurisdiction-token op, de wrapper keek in Redis onder jurisdiction:declarant-NL-0042, vond een verse entry en gaf die terug. Het token was geldig. Alleen geldig voor het verkeerde bedrijf.
Als een cache-key voor een auth-artefact geen tenant id bevat, heb je geen cache. Je hebt een same-key collision die wacht op de tweede tenant.
Waarom de retry-loop het niet ving
De tool-use loop van Claude maakte het erger. De submit-tool van de agent geeft structured errors terug. Wijst het douane-endpoint af met een authenticatie- of jurisdiction-fout, dan hoort de loop hard te stoppen. Maar om 09:02:11 faalde een routinematige AGS-call met een transient netwerkfout. De wrapper deed automatisch een retry. Het retry-pad las het token niet opnieuw uit bij de auth-service. Het haalde het uit de Redis-cache, want dat is precies waar caches voor zijn.
De retry slaagde. Het model zag een 200, vinkte de tool-call af en ging door. Net als de volgende 939 calls.
De retry was niet de root cause. De cache-key was dat. Maar de retry-loop maakte een trage fout snel. Anthropic is in hun tool-use richtlijnen expliciet: het model vertrouwt op wat de tool teruggeeft. Zegt je tool ok, dan gaat de agent daar niet aan twijfelen.
Wat we naar de Douane stuurden
Om 10:30 had de douane-accountmanager van de expediteur een lijst van alle 916 betrokken MRN's en de gecorrigeerde EORI-mapping. De standaardroute voor een aangiftefout in DMS is een regularisatieverzoek bij het douanekantoor dat de aangifte heeft aanvaard. De Nederlandse douane accepteert die schriftelijk, met een gecorrigeerde aangifte erbij. Diezelfde middag diende de expediteur de regularisaties in. Goederen die nog niet waren vrijgegeven werden onder borg gehouden en opnieuw aangegeven onder de juiste EORI. Goederen die al wel waren vrijgegeven gingen door een correctie achteraf.
Er werd geen lading in beslag genomen. Twee invoer-btw-regels verschoven van het ene bedrijf naar het andere, wat beide finance-teams ongeveer zo voelden als je zou verwachten. Het douanekantoor in Almere had iets vergelijkbaars eerder gezien, nooit op deze schaal, en ging er nuchter mee om.
Waar we niet mee wegkwamen was de audit trail. Elke verkeerde aangifte droeg een geldige handtekening van het jurisdiction-token van de zustertenant. Die zustertenant moest schriftelijk bevestigen dat zij geen van die 916 aangiftes hadden geautoriseerd. Dat papierwerk kostte drie weken.
De per-tenant token binding gate
Er gingen twee dingen mis. De cache-key miste een dimensie. De retry-loop vertrouwde de wrapper. De fix moet beide adresseren.
Eerst: de cache-key is tenant-scoped, en de wrapper weigert een request te doen tenzij de bound tenant van het token matcht met de tenant van de call. De check draait bij elke call, niet alleen bij een cache miss:
// runs on every customs-API call, not only on token issue
function bindTokenToTenant(token: JurisdictionToken, ctx: CallContext): void {
if (token.boundTenantId !== ctx.tenantId) {
throw new TokenTenantMismatch({
tokenTenant: token.boundTenantId,
callTenant: ctx.tenantId,
declarantId: ctx.declarantId,
});
}
if (token.boundEori !== ctx.eori) {
throw new TokenEoriMismatch({
tokenEori: token.boundEori,
callEori: ctx.eori,
});
}
}
async function submitDeclaration(ctx: CallContext, payload: DmsPayload) {
const token = await jurisdictionTokens.get(
// key now contains both tenant and declarant
`jurisdiction:${ctx.tenantId}:${ctx.declarantId}`
);
bindTokenToTenant(token, ctx); // hard fail before the wire call
return dmsClient.submit(token, payload);
}
Twee: het retry-pad trekt niets meer uit de cache. Een retry forceert een vers token. De redenering: als de vorige call faalde, behandelen we het gecachete token als verdacht totdat het tegendeel bewezen is. Het kost één extra round-trip naar de auth-service bij transient errors. Het levert op dat een stale of cross-bound token een retry niet overleeft.
Drie: de tool van de agent geeft nu een getypeerde error terug bij een tenant-mismatch, en de tool-use loop is zo geconfigureerd dat de hele batch stopt op die error-klasse. Dit was het stuk dat werk vroeg in de prompt en in het tool-schema. Voorheen kreeg de agent te horen om auth-achtige fouten te retryen met backoff. De nieuwe instructie is expliciet: een TokenTenantMismatch wordt nooit geretryed, nooit gelogd-en-doorgegaan en nooit samengevat in een post-batch rapport. Hij paged een mens.
// tool result schema — the model can no longer interpret this
type SubmitResult =
| { ok: true; mrn: string }
| { ok: false; retryable: true; category: "transient_network" | "rate_limited" }
| { ok: false; retryable: false; category:
| "tenant_mismatch"
| "eori_mismatch"
| "schema"
| "rejected" };
De retryable flag wordt door onze wrapper gezet, niet door het model afgeleid. We hebben het op de harde manier geleerd: een model de ruimte geven om te beslissen wat als hetzelfde probleem geldt, gewoon nog eens proberen werkt prima voor een idempotente GET en is gevaarlijk voor alles wat een juridisch bindend document aanmaakt.
De checklist die we nu op elke douane-integratie draaien
Elke douane-integratie die we opleveren moet langs dezelfde vijf gates voordat hij live gaat:
- Cache-keys voor auth-artefacten bevatten de tenant id. Altijd. We grepen hierop in CI.
- Het auth-artefact draagt zijn bound tenant en bound EORI in de payload, niet alleen in de lookup-key. De wrapper checkt beide bij elke call.
- Retry-paden halen auth-artefacten opnieuw op. Caches zijn voor het happy path.
- Het tool-schema van de agent onderscheidt retryable van non-retryable errors. Het model mag niet herclassificeren.
- Tenant-mismatch errors paged bij de eerste keer dat ze voorkomen. Ze rollen niet op in een dashboard van 5% failure rate.
Dat vijfde punt hebben we onderschat. Het batch-dashboard van de agent liet tussen 09:02 en 09:14 100% success zien, want vanuit de wrapper bezien slaagde elke call. Het douanesysteem wees de aangiftes downstream af, maar die afwijzingen kwamen binnen via het inbox-kanaal, niet via het API-kanaal. De dienstdoende engineer zat naar het verkeerde dashboard te kijken.
Wat dit incident niet was
Dit was geen Claude-bug. Het model deed wat het opgedragen kreeg met de tools die het had. De cache-key was fout, het retry-pad was fout en het tool-schema was te ruim. Stuk voor stuk dingen die een nette engineer kan schrijven, kan deployen en veertien maanden lang niet opmerkt zolang er maar één tenant op het systeem zit.
Het was ook geen multi-tenant SaaS-probleem in de gebruikelijke zin van die uitdrukking. De cross-tenant leak kwam niet uit een vergeten WHERE in een database-query. Hij kwam uit een cache die ouder was dan de multi-tenancy en bij de komst van de tweede tenant nooit opnieuw is gekeyed. Dat is in onze ervaring de vakerere vorm van dit soort bugs: de database is in orde, de applicatiecode is in orde, en de infrastructuur sleept een oude aanname mee die niemand opnieuw heeft gecheckt.
Het kleinste dat je vandaag kunt doen
Toen we de douane-wrapper van de expediteur herbouwden als AI-agent, liepen we ertegenaan dat de cache-key tenant-scopen een wijziging van één regel was en het tenant-binden van de token-payload een verandering van twee weken met drie leveranciers erbij. De cache-key is de goedkope fix. De payload binding is de fix die je redt op het moment dat iemand de cache-key vergeet. Wanneer wij nu douane-agents opleveren, leveren we beide.
Open een terminal. Grep je auth-gerelateerde caches op de substring tenant. Bevat een key die een auth- of jurisdiction-artefact aanraakt geen tenant-identifier, dan heb je het probleem van vandaag. Fix die eerst.
Kern
Als een cache-key voor een auth-token de tenant id niet bevat, heb je geen cache. Je hebt een same-key collision die wacht op de tweede tenant.
FAQ
Wat is DMS Inbreng?
DMS Inbreng is het Nederlandse kanaal voor invoeraangiftes onder het Declaration Management System van de Douane. Het is het systeem waar invoeraangiftes via worden ingediend en geaccepteerd.
Hoe belandde een token van een andere tenant op deze aangiftes?
De Redis cache-key van het jurisdiction-token was alleen gescoped op declarant id, niet op tenant id. Het token van een zustertenant stond al onder die gedeelde key in de cache toen de agent van onze expediteur er een opvroeg.
Waarom ving de tool-use loop van Claude de verkeerde EORI niet?
Na de stille retry gaf de wrapper een 200 success terug. Het model vertrouwt het antwoord van de tool. Zonder een structured tenant-mismatch error was er niets waarop de loop kon stoppen.
Wat is een per-tenant token binding gate?
Een check die voor elke douane-API-call draait en verifieert dat de bound tenant en de bound EORI van het auth-token kloppen met de call-context. Klopt er één van de twee niet, dan faalt hij hard in plaats van de cache te vertrouwen.
Hoe heeft de expediteur de 916 verkeerde aangiftes gecorrigeerd?
Via regularisatieverzoeken bij het aanvaardende douanekantoor, met gecorrigeerde aangiftes erbij. Nog niet vrijgegeven goederen werden onder borg opnieuw aangegeven. Vrijgegeven goederen liepen via een correctie achteraf.