← Blog

AI agents

AI-agent audits: negen Stripe-refunds, één checklist

Een agent die we uitrolden voerde dezelfde Stripe-refund negen keer uit op één klantsaldo na een checkpoint-crash. Dit is de audit die we nu draaien.

Jacob Molkenboer· Oprichter · A Brand New Company· 25 apr 2025· 8 min
Messing relais naast negen overlappende carbonbonnetjes, één met groen tabje, messing bel en grootboekkaart erachter.

Het Stripe-dashboard toonde negen refunds op dezelfde charge, twee minuten uit elkaar, allemaal uitgevoerd door een agent die we hadden uitgerold. De klant was 47 EUR afgeschreven. Tegen de tijd dat de on-call engineer op pauze drukte, hadden we haar 423 teruggestort.

Dat is het soort bug dat je maar één keer mag schrijven. Nadat we de klant een eerlijke mail hadden gestuurd en de duplicates hadden teruggedraaid, gingen we zitten en schreven we een checklist. Elke agent die we sindsdien hebben uitgerold loopt eerst die checklist door, voordat hij een connection string krijgt voor de productie-Postgres van een klant.

Deze post is die checklist.

De daadwerkelijke fout

De agent was een refund-handler voor een kleine e-commerce-klant. Een menselijke customer-service-medewerker keurde een refund goed in een back-office tool. De agent, gebouwd op LangGraph met de standaard SQLite-checkpointer, doorliep drie stappen: order laden, refund-bedrag bepalen, Stripe aanroepen.

De derde stap crashte. De Stripe API gaf een 503 terug tijdens een routine-incident. Onze retry policy, zes maanden eerder in haast geschreven, behandelde 503 als transient en herhaalde de hele node. De LangGraph-checkpointer slaat state, by design, alleen op bij succesvolle afronding van een stap. Een crash midden in een stap betekent dat de resume die stap weer vanaf het begin start. De stap had geen idempotency key op de Stripe-call. Dus elke retry, plus elke checkpoint-resume na een herstart van het proces, gaf een nieuwe refund uit.

De les die we je hieruit mee willen geven is niet "LangGraph is kapot". LangGraph gedraagt zich precies zoals gedocumenteerd. De les is: een gecheckpointe agent die werk doet buiten zichzelf, vraagt om dezelfde zorgvuldigheid als een payment processor zelf. Dat geldt voor Burr, Temporal en elk ander framework met een at-least-once persistence-model. En dat zijn er bijna allemaal.

Side effects in hun eigen tabel

Elk extern side effect hoort in je eigen database geregistreerd te staan voordat je de call doet, met een deterministisch id dat je bij een retry kunt opzoeken. Dit is het transactional outbox pattern, alleen omgedraaid: we schrijven eerst onze intentie weg, dan roepen we de buitenwereld aan, en daarna schrijven we het resultaat terug.

def issue_refund(state):
    op_id = f"refund:{state['order_id']}:{state['attempt_token']}"
    with db.transaction() as tx:
        row = tx.execute(
            "select stripe_id, status from refund_ops "
            "where op_id = %s for update",
            (op_id,),
        ).fetchone()
        if row and row["status"] == "succeeded":
            return {"stripe_id": row["stripe_id"]}
        if not row:
            tx.execute(
                "insert into refund_ops (op_id, status) "
                "values (%s, 'pending')",
                (op_id,),
            )

    refund = stripe.Refund.create(
        charge=state["charge_id"],
        amount=state["amount_cents"],
        idempotency_key=op_id,
    )

    with db.transaction() as tx:
        tx.execute(
            "update refund_ops set stripe_id = %s, status = %s "
            "where op_id = %s",
            (refund.id, refund.status, op_id),
        )
    return {"stripe_id": refund.id}

Twee dingen maken dit veilig. Het op_id is deterministisch afgeleid uit de state van de agent, dus een resume produceert dezelfde key. En we geven die door als Stripe's idempotency_key, wat betekent dat zelfs als onze eigen tabel-check een race verliest, Stripe gewoon het oorspronkelijke refund-object teruggeeft in plaats van een nieuwe aan te maken. Stripe documenteert dit contract netjes, inclusief het 24-uurs venster waarin de garantie geldt (Stripe API: idempotent requests). Voor langlopende agents telt dat.

Dezelfde vorm geldt voor verstuurde mails, gepubliceerde webhooks, file writes en DDL. Elke actie met een side effect krijgt een rij in je database voordat de actie wordt uitgevoerd.

Database-rollen, niet alleen connection strings

De agent die negen refunds uitgaf, verbond als een user met SELECT, INSERT, UPDATE, DELETE op elke tabel in het public schema. Hij had alleen INSERT en UPDATE nodig op twee tabellen. De harde manier geleerd: een agent hoort te verbinden als een rol die alleen kan doen wat zijn tools zeggen dat hij mag doen.

CREATE ROLE agent_refund_writer LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE app TO agent_refund_writer;
GRANT USAGE ON SCHEMA public TO agent_refund_writer;

GRANT SELECT ON orders, customers
  TO agent_refund_writer;
GRANT INSERT, UPDATE ON refund_ops, agent_audit_log
  TO agent_refund_writer;

REVOKE CREATE ON SCHEMA public FROM agent_refund_writer;
ALTER ROLE agent_refund_writer SET statement_timeout = '5s';
ALTER ROLE agent_refund_writer SET lock_timeout = '2s';

De rol kan twee tabellen lezen, twee tabellen schrijven, kan geen objecten aanmaken, kan geen statement langer dan vijf seconden draaien en wacht niet langer dan twee seconden op een lock. Het Postgres rol-ontwerp (PostgreSQL: database roles) is ouder dan welk agent-framework dan ook. Gebruik het.

Voor agents die nominaal "read only" zijn, maken we alsnog een aparte rol aan met SELECT op een whitelist van tabellen. "Read only" zonder een audit-rol is een beleefd verzoek. Productie-grants zijn niet de plek voor beleefde verzoeken.

Tool-schema's als oppervlak

Elke tool die een agent kan aanroepen is API-oppervlak, en zo moet je het ook behandelen. De audit vereist:

  • Een JSON Schema voor elke tool-input, zonder additionalProperties.
  • Een handgeschreven allowlist met toegestane waarden voor elke vrije string die uiteindelijk in een SQL-identifier, file path, URL of shell-argument terechtkomt.
  • Een maximumlengte voor elke vrije string die in een prompt, een database-kolom of een uitgaande mailtekst belandt.

De regel rond vrije strings is degene die teams overslaan. Een agent die elke klantmail mag schrijven, wordt binnen een week gephisht om zichzelf een refund-goedkeuring te mailen. We hebben het zien gebeuren. De recente thread over een agent die het DN42-netwerk afzocht totdat hij de account van zijn operator leegtrok, is een andere vorm van dezelfde ziekte: onbegrensde input produceert onbegrensde actie.

Resume-semantiek, vooraf vastgelegd

De checkpointer van LangGraph, de persistence-laag van Burr, de workflow history van Temporal. Ze hebben allemaal goed gedefinieerd resume-gedrag, en dat gedrag is bijna nooit "exactly once". Het is at-least-once. De eerste vraag in de audit is: welke nodes in deze graph zijn veilig om twee keer uit te voeren?

Voor elke node taggen we hem in een comment als één van drie categorieën:

  • pure: deterministisch, geen externe calls. Veilig om opnieuw te draaien.
  • idempotent: externe calls, gedekt door een op_id. Veilig om opnieuw te draaien.
  • unsafe: mag niet opnieuw draaien. Wrap hem.

Als een node getagd blijft als unsafe, gaat de agent niet live. We refactoren hem naar idempotent, of we verplaatsen het side effect naar een queue met een eigen idempotency-verhaal. De audit-stap is een grep: elke unsafe-tag in de codebase laat de build falen.

Een kill switch in beschrijfbare infrastructuur

De agent die negen refunds uitgaf, werd vermoord met kubectl delete pod. Tussen het moment dat de operator de duplicates opmerkte en Kubernetes het proces beëindigde, gingen er nog twee refunds uit. Procesbeëindiging is geen kill switch.

Een kill switch is een rij in een tabel die de agent leest bovenaan elke stap.

def check_kill_switch(state):
    row = db.execute(
        "select paused, paused_reason from agent_controls "
        "where agent_id = %s",
        (state["agent_id"],),
    ).fetchone()
    if row and row["paused"]:
        raise AgentPaused(row["paused_reason"])

De on-call engineer flipt de rij vanuit psql. De agent stopt voor zijn volgende side effect. Hetzelfde patroon dekt pauzes per klant, pauzes per actie en volledige shutdown. De kosten zijn één extra query per stap. Het is het waard.

Waarschuwing

Als je kill switch ergens leeft waar de agent niet bij elke stap checkt, is het geen kill switch. Het is een hoop.

Kosten- en rate-plafonds

De kosten van een agent zijn onbegrensd tenzij iets ze begrenst, en dat iets is niet de agent zelf. Voor elke uitgerolde agent stellen we in:

  • Een hard model-token-budget per run, afgedwongen in de wrapper rond de model-client.
  • Een hard maximum aantal tool-calls per run.
  • Een hard plafond op uitgaven aan externe API's, waar van toepassing, afgedwongen door een lopende totaal-check in Postgres voor elke call.

We rate-limiten de wrapper ook op procesniveau. Een agent die in twee minuten negen keer een refund opnieuw probeert, is geen "refund-agent". Het is een denial-of-wallet-aanval op zijn eigen klant. De retry policy hoort bij de agent. Net als het plafond op wat die retry policy mag kosten.

De dry run die productie nabootst

Het laatste punt op de checklist is degene die vangt wat de andere punten missen. We richten de agent op een kopie van productiedata, met een Stripe-test-key en een wegwerp-Postgres-rol, en draaien hem op de laatste 200 echte cases die het menselijke team afhandelde. We diffen de acties die de agent voorstelt tegen de acties die het team daadwerkelijk uitvoerde. Een senior engineer leest de diff met de hand.

Dit is langzaam. Het kost een halve dag per agent. Het heeft twee gevallen gevangen waarin een agent de verkeerde klant terugbetaalde (een join-condition-bug die geen unit test had betrapt), één geval van een agent die een Nederlandstalige mail naar een Vietnamese klant stuurde (een locale-lookup die defaulte naar nl-NL), en één geval van een agent die zelfverzekerd een chargeback goedkeurde omdat de prompt twee keer het woord "alsjeblieft" bevatte. Geen van deze werd opgemerkt door de testsuite. Allemaal werden ze gevangen door de diff.

De diff is ook de plek waar je erachter komt of je agent te enthousiast is. Frameworks belonen vandaag proactiviteit. Productie beloont terughoudendheid. Lees de diff en zie wat van de twee je hebt uitgerold.

Het kleinste wat je vandaag kunt doen

Open één van je agents. Zoek de node die de buitenwereld aanroept. Stel de vraag: wat gebeurt er als deze node twee keer draait? Als het antwoord "geen idee" is, dan is dat je audit. Begin daar.

Toen we de refund-agent bouwden voor die e-commerce-klant, was de bug die ons deze checklist leerde precies de checkpoint-resume-fout. We hebben het opgelost door elk side effect achter een op_id-rij en een Stripe idempotency key te zetten, en door de rest van de lijst hierboven op te schrijven, zodat de volgende agent die we uitrolden niet zijn eigen incident nodig had om ze te vinden. Wil je een tweede paar ogen op de AI-agents die je in productie of in ontwikkeling hebt, dan is dat het werk dat we doen.

Kern

Een agent die productie aanraakt heeft idempotency keys nodig, een afgebakende Postgres-rol, een row-level kill switch, en een dry run op 200 echte cases voordat hij live gaat.

FAQ

Waarom speelde de LangGraph-checkpointer de refund-stap opnieuw af?

Checkpointers slaan state pas op bij succesvolle afronding van een stap. Een crash midden in een stap zorgt dat de resume die stap vanaf het begin herstart, oftewel at-least-once. Zonder idempotency key wordt het side effect opnieuw uitgevoerd.

Is een Stripe idempotency key op zichzelf genoeg?

Het dekt het venster van dubbele calls (24 uur), maar niet het geval waarin je eigen database niet weet dat een call al gelukt is. Combineer de idempotency key met een rij in je eigen tabel die je schrijft vóór de call.

Welke database-rol moet een agent gebruiken?

Een aparte Postgres-rol met grants op een whitelist van tabellen, geen CREATE op het schema, en korte statement- en lock-timeouts. Hergebruik nooit de hoofdrol van de applicatie voor een agent.

Hoe stop je daadwerkelijk een lopende agent tijdens een incident?

Een rij in een controls-tabel die de agent bovenaan elke stap leest. Procesbeëindiging is te traag en laat de stap die al onderweg was gewoon afronden, vaak met nog een side effect.

ai agentsautomationarchitecturesecurityoperationsintegrations

Iets bouwen?

Start een project