AI agents
Claude Fable casing-drift: HR-agent verminkt 3.400 rijen
Om 06:40 Amsterdamse tijd sloeg de schema-validator alarm op 3.400 kandidaat-intake-rijen met null voornaam. De onboarding-agent had ze de hele nacht doorgeschreven.

Om 06:40 Amsterdamse tijd op een woensdag ging de on-call pager af bij een HR-tech SaaS van 22 mensen in Apeldoorn. Een nachtelijke schema-validator had 3.407 kandidaat-intake-rijen uit de afgelopen acht uur eruit gepikt. De kolom first_name was op alle rijen null. De agent had ze vrolijk weggeschreven, de database had ze vrolijk geaccepteerd, en de validator had vrolijk gewacht tot zijn 06:40-cron om het op te merken.
Het product is een onboarding-agent. Nieuwe medewerkers uploaden hun ID, de agent extraheert de velden, roept een tool aan om een kandidaat-rij toe te voegen, en routeert ze naar de juiste manager. Standaardwerk. De agent draait al veertien maanden in productie. Aan zijn prompt, tool-schemas en downstream code was de afgelopen zes weken niets veranderd.
Wat wel was veranderd: om 22:15 de avond ervoor had het team het model-id omgezet van de vorige Sonnet-build naar claude-fable-5. Geen rollback. Geen redeploy. Eén string in één config-bestand gewijzigd en gepusht. De blast radius bleek een casing-conventie. Dit is wat er gebeurde, en wat we nu anders doen bij een model-swap op een live agent.
De agent-loop, voor en na
De agent had één tool die er voor dit verhaal toe deed: create_candidate. Het schema zag er ongeveer zo uit.
{
"name": "create_candidate",
"description": "Insert a new candidate row from extracted ID fields.",
"input_schema": {
"type": "object",
"properties": {
"first_name": { "type": "string" },
"last_name": { "type": "string" },
"dob": { "type": "string", "format": "date" },
"nationality": { "type": "string" }
},
"required": ["first_name", "last_name", "dob"]
}
}
Op het oude model kwamen tool-call-argumenten terug in snake_case, precies zoals gedeclareerd. Op het nieuwe model kwamen ze, om redenen die we zo behandelen, terug in camelCase. Het schema werd door geen van beide versies betrouwbaar als contract op veldnamen gerespecteerd, maar de vorige build leunde in ongeveer 99,6% van de calls naar snake_case. De nieuwe build leunt de andere kant op.
De tool-handler deed dit:
// services/candidates.ts
export async function createCandidate(args: Record<string, unknown>) {
await db.insert("candidates", {
first_name: args.first_name,
last_name: args.last_name,
dob: args.dob,
nationality: args.nationality,
})
}
Je ziet de bug al. args.first_name is undefined zodra het model firstName teruggeeft. De insert lukte omdat elke kolom nullable was. De rij ging erin. De kandidaat verscheen in het dashboard met een leeg naamveld en geen nationaliteit. De manager-routing keek naar de kandidaat-id en niet naar de naam, dus de kandidaat werd alsnog goed gerouteerd. Niemand zag een kapotte UI. Niemand kreeg een exception in Sentry. De agent-loop deed geen retry, want vanuit zijn perspectief gaf de tool gewoon 200 OK terug.
Waarom de keys veranderden
We weten niet precies waarom het nieuwe model camelCase prefereerde. Het beeld vanuit het team: frontier models absorberen naamgevingsconventies uit hun trainingscorpus, en JavaScript- en TypeScript-code domineren dat corpus. snake_case is een Python- en SQL-conventie; camelCase is een JS-conventie. Het schema declareerde snake_case, maar JSON Schema verankert veldnaamgeving niet als invariant op de manier die je misschien aanneemt.
Het model kon het schema lezen als "produceer JSON met deze vier keys, van deze types, met deze drie verplicht" en JSON produceren die aan die voorwaarden voldoet onder elke aliasing die het natuurlijk vond. Klinkt als een model-bug. Dat is het niet. Het is een schema dat te ruim gespecificeerd is voor wat we nodig hadden. Als een contract meerdere correcte lezingen toelaat, ga er dan vanuit dat je ze allemaal vroeg of laat ziet, en dat de verdeling verschuift zodra de schrijver aan de andere kant verandert.
Strict mode die geen strict mode was
Het team had strict: true op de tool-definitie staan. Ze gingen ervan uit dat het model dan geen argumenten kon teruggeven die niet aan het schema voldeden. Wat het werkelijk betekent, volgens Anthropics tool-use documentatie, is dat het model verplicht is JSON te produceren die past binnen de gedeclareerde types. Veldnaamgeving valt op papier ook onder dat contract. In de praktijk behandelde de validator in hun SDK-versie extra velden als additional properties en liet ze stilzwijgend door.
De oorzaak is mechanisch. De input_schema van de tool was een gewoon JSON Schema-object met properties gedeclareerd en zonder additionalProperties: false. Standaard staat JSON Schema additional properties toe. De schema-generator die het team gebruikte had die vlag nooit gezet, omdat het vorige model de gewoonte niet had om extra's terug te geven. Toen het nieuwe model first_name als firstName begon te sturen, zag de validator dat als een onbekende maar toegestane extra property, en de vier gedeclareerde properties ontbraken simpelweg in de payload. Ontbrekende optionele properties worden geaccepteerd; de verplichte ontbraken technisch ook, maar de strict-mode-afdwinging in deze laag van de SDK draaide op declared-type checks voor aanwezige velden, niet op een afsluitende sweep op required keys tegen de payload die hij ging teruggeven.
Dat is niet de schuld van het model. Het is het gat tussen "het schema zegt snake_case" en "de validator weigert al het andere." De keten was:
- Model geeft
{ "firstName": "Iris", "lastName": "de Vries", ... }terug. - SDK accepteert de tool-call als schema-valide JSON.
- Handler leest
args.first_name, krijgtundefined. - Insert draait met nulls. Geen error.
Strict mode in de SDK maakte van onverwachte casing geen harde reject. Het model produceerde valide JSON. De keys die het koos waren alleen niet de keys waar de handler om vroeg.
Als je tool-handler argumenten leest op letterlijke key-naam en je database-kolommen nullable zijn, kan een gedragsverschuiving in een model uren lang rommel wegschrijven voordat iets alarm slaat. Je error budget is precies het gat tussen "data komt binnen" en "data wordt gelezen."
De 06:40-melding en de 47 minuten daarna
De validator die wij draaien is een nachtelijke Pydantic-check tegen een strict-mode model dat het database-schema spiegelt. Hij draait om 06:40 omdat de ops-lead in Apeldoorn de resultaten vóór standup wil zien. Hij vlagde 3.407 rijen met null first_name en null nationality. De fingerprint was op elke rij identiek: dezelfde twee velden leeg, alle andere velden aanwezig.
Die fingerprint was de geluksvariant. Binnen tien minuten wisten we dat het geen database-bug, geen netwerkhik en geen kapotte upload was. Twee specifieke kolommen gingen synchroon naar null, op elke rij, acht uur achter elkaar. Dat is een code path, geen outage.
De business impact was tegen die tijd al zichtbaar. De HR-tech klant in Apeldoorn had veertien zakelijke accounts op het platform; elk had een deel van de nachtelijke aanmeldingen gekregen. Ochtenddienst-managers waren al cases aan het openen waarin ze een kandidaat niet via de naam konden vinden in de zoekbalk, omdat de naam leeg was. Zes van hen waren al kandidaten gaan bellen om gegevens te checken, exact het werk dat de agent moest wegnemen. Een fix-window van 47 minuten doet ertoe als het alternatief is dat de klant van jouw klant twee keer wordt gebeld vóór 09:00.
De on-call engineer trok drie dingen tegelijk op:
- Het model-id in de productie-config:
claude-fable-5. - De laatste twintig rauwe tool-call-payloads uit de request log.
- De diff van de agent-prompt en tool-schemas over de afgelopen 30 dagen.
De rauwe payloads waren het rokende pistool. Iedere teruggegeven payload had camelCase-keys. Payloads van 22:00 de avond ervoor, vóór de model-swap, waren snake_case. Payloads vanaf 22:30 waren camelCase. Vanuit het model gezien zijn beide valide JSON voor een schema dat casing niet als invariant vastlegt.
De fix
Om 07:27 stond de fix in productie. Drie wijzigingen:
// services/candidates.ts
import { z } from "zod"
const KEY_MAP: Record<string, string> = {
firstName: "first_name",
lastName: "last_name",
dateOfBirth: "dob",
}
function normalize(args: Record<string, unknown>) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(args)) {
out[KEY_MAP[k] ?? k] = v
}
return out
}
const CandidateSchema = z.object({
first_name: z.string().min(1),
last_name: z.string().min(1),
dob: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
nationality: z.string().min(2),
}).strict()
export async function createCandidate(rawArgs: Record<string, unknown>) {
const args = CandidateSchema.parse(normalize(rawArgs))
await db.insert("candidates", args)
}
Het Zod-schema staat op .strict(), wat onbekende keys weigert in plaats van ze stilzwijgend te droppen. De handler normaliseert eerst de camelCase-aliases die we kennen voordat hij parset. Als een toekomstig model iets stuurt dat we niet hebben gemapt, throwt de parse, krijgt de agent een error terug op de tool-call, en doet hij een retry. Luid is beter dan stil. Pydantic's strict mode heeft dezelfde vorm op de Python-services die we draaien.
Backfill ging soepel omdat het team de rauwe model-output had bewaard. We hebben de ontbrekende velden opnieuw uit de oorspronkelijke tool-call-payloads geëxtraheerd en de rijen gepatcht. Om 09:15 was de data schoon en hadden de managers hun ochtend ingehaald. We hebben het incident gelogd, de rauwe payloads als regressie-fixture bewaard, en een unit-test toegevoegd die controleert dat de handler beide casings tolereert.
Drie dingen die we nu anders doen bij model-rollouts
Het incident ging eigenlijk niet over casing. Het ging over drie aannames die zachter bleken dan gedacht.
1. Een model-id wisselen is een deployment, geen config-toggle
Een model-id omzetten hoort dezelfde review en canary te krijgen als een code change. Het team had het als config-toggle behandeld. Nu staat het model-id per environment vastgepind, gaan wijzigingen via een PR, en draait er 24 uur lang een 1% canary tegen een shadow-handler die tool-call-payloads diff-checkt tegen de laatste bekend-goede verdeling.
De shadow-handler draait parallel aan de productie-handler op het canary-aandeel van het verkeer. Elke payload wordt genormaliseerd, gehashed op zijn key-set, en vergeleken met de verdeling uit de afgelopen zeven dagen. Als meer dan 2% van de payloads in een venster van 30 minuten een key-set laat zien die we nog nooit hebben gezien, faalt de canary en is de rollback automatisch. In Slack komt een diff binnen met de specifieke nieuwe keys, het model-id en een voorbeeld-payload. Twee operations engineers lezen dat bericht in een minuut en weten of het door kan of terug moet.
2. Nullable kolommen zijn riskant zodra een agent erin schrijft
Mensen die een formulier invullen krijgen een required-field-error. Agents die via een SDK schrijven krijgen een optimistische insert. We hebben elke kolom op agent-geschreven tabellen NOT NULL gemaakt, of in een domein-guard verpakt die throwt vóór de insert. De database is de laatste eerlijke reviewer; laat hem zijn werk doen. Waar een kolom om legitieme reden nullable moet blijven, draagt de tabel nu een CHECK-constraint die controleert of de rij als geheel klopt, in plaats van elke kolom zijn eigen vrijbrief te laten schrijven.
3. Het contract moet ook aan jouw kant worden afgedwongen
Strict mode in een tool-SDK is een eigenschap van de parser, niet van het contract. Elke tool-handler in productie haalt argumenten nu door een Zod- of Pydantic-strict-parser voordat de database wordt aangeraakt. Onbekende keys zijn een harde reject. De agent krijgt een gestructureerde error terug en probeert het opnieuw. We loggen elke parse-fout met de rauwe payload, het model-id en de versie van de agent-prompt, zodat we een gedragsverschuiving in een model binnen een uur zien, niet binnen acht.
Er zit een bredere les in die nu door de hele industrie opkomt naarmate nieuwe model-versies vaker worden uitgerold. Gedragsverschuivingen in frontier models slaan geen alarm; ze degraderen output op manieren die je bestaande tests niet dekken. Vertrouwen in een model-output is niet hetzelfde als een garantie ervoor, en de enigen die het vóór hun klanten doorhebben zijn degenen die de validators zelf hebben gebouwd. De teams die dit goed doen schrijven minder prompts en meer parsers.
De vijf-minutencheck die je vandaag kunt doen
Heb je vandaag een agent in productie en heb je tot hier gelezen, dan is dit een check van vijf minuten. Open één tool-handler. Zoek de regel die een argument op letterlijke key-naam leest. Stel jezelf twee vragen.
Eén: als het model dezelfde data onder een iets andere key-naam teruggaf, zou je handler het merken? Twee: als hij het niet merkte, wat is de ergste rij die hij naar de database zou kunnen schrijven? Als het antwoord op de tweede vraag is "een rij die geen validator urenlang oppakt," fix dan eerst die ene handler. De rest kan wachten.
Toen we de onboarding-agent voor de HR-tech klant in Apeldoorn bouwden, was precies dit het gat waar we tegenaan liepen: tussen "JSON parset prima" en "de rij betekent ook wat hij moet betekenen." We hebben het opgelost door elke tool-handler in een strict schema-parser te wrappen en de shadow-canary aan elke model-swap toe te voegen, het soort review op AI-agents dat we nu standaard draaien op elke productie-deployment.
De kleinste actie van vandaag: grep je tool-handlers op directe key-reads. Tel ze. Dat getal is je blast radius de volgende keer dat een model-gedrag onder je verschuift.
Kern
Een model-rollout is een deployment, geen config-wijziging. Pin de versie, canary de swap, en weiger onbekende keys bij de tool-handler.
FAQ
Wat is schema-drift in een AI-agent-context?
Wanneer de vorm van model-outputs (veldnamen, casing, optionele keys) tussen rollouts verschuift zonder code change, en downstream handlers de payload stil verkeerd lezen terwijl de JSON zelf valide blijft.
Waarom ving strict mode de casing-wijziging niet op?
Strict mode beperkte JSON-types, niet veldnaamgeving. Het model gaf valide JSON terug met camelCase-keys; de handler las snake_case-keys en kreeg undefined. De SDK-validator liet onbekende keys door omdat het schema geen additionalProperties: false had gezet.
Hoe snel moet een model-rollout omkeerbaar zijn?
Binnen minuten. Pin het model-id per environment, zet de switch achter een feature flag, en draai een 1% canary met een shadow-handler die tool-call-payloads diff-checkt tegen de laatste bekend-goede verdeling.
Mogen tabellen waar een agent in schrijft nullable kolommen hebben?
Behandel agent-geschreven kolommen hetzelfde als verplichte formuliervelden. Maak ze NOT NULL of wrap ze in een domain-guard. Een agent die nulls wegschrijft is het equivalent van een formulier dat gebruikers een leeg verplicht veld laat indienen.
Hoe herstel je records die door een stille agent-bug zijn stukgegaan?
Bewaar de rauwe model-output voor elke tool-call. Als een downstream handler een rij corrumpeert, kun je de juiste waarden opnieuw afleiden uit de oorspronkelijke payload, zonder het model opnieuw te draaien of de gebruiker nogmaals te belasten.