Vorige keer liet ik een bug zien die door de tests gecertificeerd was. Het ticket zei dat een rit langer dan 100 km een toeslag krijgt. De code zei >= 100. De tests, door dezelfde AI in dezelfde sessie geschreven, waren het eens met de code.

De oplossing in die post was herkomst: leid de testgevallen af uit het ticket, niet uit de implementatie. Maar ik sloeg een vraag over. Wat stond er eigenlijk in dat ticket?

Dit, zoals de meeste tickets eruitzien:

A trip longer than 100 km gets a 10% long-distance surcharge.

## Acceptance criteria
- [ ] Surcharge implemented in `Fare.Surcharge`
- [ ] Unit tests added
- [ ] `dotnet test` passes

Alle drie de vinkjes stonden er terecht. De bug ging toch live.


Het criterium dat gehaald werd

Kijk wat die drie regels beschrijven. Een methode om aan te passen, een soort bestand om toe te voegen, een commando om te draaien. Dat is een takenlijst voor het werk, geen beschrijving van het resultaat.

En takenlijsten zijn precies waar een AI-agent goed in is. Hij implementeert de methode, voegt de tests toe, draait dotnet test en ziet groen. Elk criterium is gehaald, en geen van allen had de bug tegengehouden.

Een criterium waar kapotte code aan voldoet, is geen criterium. Het is een formaliteit.

Er is nog een tweede probleem, en dat duurt langer voor je het ziet: commando’s verouderen.

Ik bouw een iOS-app met een team van AI-agents, en de issues daar hadden vroeger verificatiecommando’s in hun criteria. Eén criterium zei letterlijk: draai de build met deze flag.

Een paar weken later zeiden de regels van het project: gebruik die flag niet meer. Het issue eiste hem nog steeds.

De agent die het oppakte had twee autoriteiten die elkaar tegenspraken. Welke hij ook volgde, de andere brak hij.

Een commando beschrijft hoe je iets vandaag controleert. Gedrag beschrijft wat waar moet zijn, ongeacht hoe je het controleert. Alleen het tweede overleeft een wijziging in je build.


Slecht en goed, naast elkaar

De regel die ik nu hanteer: een criterium beschrijft iets wat een buitenstaander kan waarnemen, of bewijs dat moet bestaan. Een gebruiker, een API-response, een testresultaat. Nooit een commando, een bestandsnaam of een methodenaam.

Hetzelfde ticket, criterium voor criterium:

Beschrijft het werkBeschrijft het resultaat
dotnet test slaagtEen rit van precies 100 km krijgt geen toeslag
Toeslag geïmplementeerd in Fare.SurchargeEen rit van 100,1 km krijgt 10% van het basistarief erbovenop
Unit tests toegevoegdEr bestaat een test die faalt op de huidige main
Gebruik > in plaats van >=(dat is een oplossing, geen criterium)

De rechterkolom heeft één eigenschap die de linker mist: de >=-bug zakt ervoor. Precies 100 km staat erin, in gewone woorden, opgeschreven door een mens voordat er code bestond.

Die laatste rij is de valkuil. Zodra je de oplossing kent, is het verleidelijk om die als criterium op te schrijven.

Maar “gebruik >” controleer je door de diff te lezen, en een diff kan kloppen terwijl het gedrag ergens anders nog steeds fout is. “Precies 100 km krijgt geen toeslag” controleer je door de code te draaien.

Kijk ook wat de rechterkolom doet voor de review. Een reviewer kan elke regel afvinken zonder Fare.cs te openen. Dat maakt het een contract in plaats van een hint: je kunt het van buitenaf controleren.


Rood op de oude code

Het derde criterium rechts is het criterium dat ik zou houden als ik er maar één mocht houden:

Er bestaat een test die faalt op de huidige main.

Een test die groen is op de oude code én groen op de nieuwe, bewijst niets over de wijziging. Het kan een prima test zijn. Bewijs dat dit issue opgelost is, is het niet.

De goedkoopste manier om het te controleren, vóór je commit: zet de fix opzij en houd de tests:

git stash push -- src/
dotnet test --filter "FullyQualifiedName~FareTests"
git stash pop

Is die run rood, dan ziet de test de bug. Is hij groen, dan is de test decoratie, hoe goed hij er ook uitziet.

Dat fragment hoort in je werkwijze, niet in het issue. Het criterium zegt wat waar moet zijn. Hoe je dat bewijst, met een stash, een cherry-pick of een CI-job, bepaalt degene die het werk doet.

Het is ook een goedkoper neefje van de mutation testing uit de vorige post. Stryker bewijst dat je suite überhaupt kan falen. Dit bewijst dat één specifieke test faalt op één specifieke bug.

Daar heb je geen tool voor nodig, alleen een zin in het issue.


Contract versus richting

In de iOS-app die ik eerder noemde, heeft elk issue nu twee secties, met een verschillend gewicht.

Acceptatiecriteria zijn het contract. Ze zijn bindend, ze beschrijven gedrag of bewijs, en het werk is pas klaar als elk ervan klopt.

Richting is een suggestie: “zit waarschijnlijk in Fare.Surcharge”, “kijk naar de vergelijking op de grens”. De agent mag die negeren als hij een betere route vindt.

Voordat hij begint, leest de agent allebei en vergelijkt ze. Spreekt de richting het contract tegen, dan wint het contract en meldt de agent dat in de pull request. Hij kiest niet stilletjes een van de twee.

Die splitsing is geen bureaucratie. Het geeft het verouderende spul een eigen plek.

Implementatiehints verouderen zodra iemand refactort, gedrag niet. Houd ze in aparte secties, en een hint kan verouderd zijn zonder dat het issue fout wordt.

Dit is een ander werk dan planning mode. Plannen is nadenken voordat je codeert. Dit is opschrijven wanneer je klaar bent, voordat iemand nadenkt over hoe.


De cirkel sluiten

Als het werk gemerged is, sluit de agent het issue niet af met “Klaar ✅”. Hij plaatst een slotcommentaar waarin hij elk criterium afvinkt, met het bewijs ernaast. De testnamen komen uit de theory in de vorige post. Dat is de versie die vanuit het ticket geschreven is in plaats van vanuit de code:

- [x] Een rit van precies 100 km krijgt geen toeslag
      → Trips_longer_than_100km_get_a_10_percent_surcharge(100, 100, 0)
- [x] Een rit van 100,1 km krijgt 10% van het basistarief erbovenop
      → dezelfde theory, rij (100.1, 100, 10)
- [x] Er bestaat een test die faalt op de huidige main
      → rood op a1b2c3d met de fix opzij gezet, groen op e4f5a6b

“Klaar” is een bool. Die kun je niet controleren, alleen geloven.

Een lijst met bewijs per criterium kan een reviewer regel voor regel nalopen. Het is hetzelfde idee als return the proof, not the answer, één niveau hoger: van een methodesignatuur naar een issue.

Het geeft de menselijke review-gate ook iets concreets te doen. De reviewer hoeft niet te reconstrueren wat de wijziging moest bereiken. Hij legt het slotcommentaar naast de criteria en zoekt het gat.


Wanneer je dit niet moet doen

Spikes en verkennend werk. Als je nog niet weet wat waar moet zijn, kun je het ook niet opschrijven. Schrijf een vraag in plaats van criteria, en maak een antwoord het op te leveren resultaat.

Pure refactors. Als het gedrag niet mag veranderen, is “de bestaande tests blijven groen” een prima criterium. Het gedrag is de bestaande suite. Controleer alleen eerst of die suite het deel dekt waar je aan zit, voordat je erop leunt.

Triviale wijzigingen. Een typfout, een configwaarde, een versie-update. Drie gedragscriteria voor een wijziging van één regel kosten meer dan ze beschermen.

Het patroon betaalt zich terug waar een agent het werk oppakt zonder dat jij erbij bent. Daar is “dotnet test slaagt” het verleidelijkst, en het minst bruikbaar.


Herschrijf één issue

Open je issue tracker en kies één open issue waar iemand deze week aan begint. Lees de acceptatiecriteria en markeer elke regel met een commando, een bestandsnaam of een methodenaam erin.

Herschrijf elke gemarkeerde regel als iets wat je kunt controleren zonder de code te openen: wat een gebruiker ziet, wat de API teruggeeft, welke waarde er op de grens uitkomt. Voeg daarna één regel toe die er nog niet stond: er bestaat een test die faalt op de huidige main.

Zet het voor en na van één criterium in de reacties. Je hebt ze allebei voor je neus zodra je klaar bent. Ik ben benieuwd welke commando’s er in ieders definition of done blijken te zitten.