Ga naar inhoud

Runbook: detection allow/deny lists and feedback

Detection is refined by two git-versioned JSON files — no rules engine, no auto-learning. Their content hash lands in every de-identify audit record (lists_hash) so you can always tell which rule version produced a document.

WORDSWORTH_DETECTION_LISTS=/etc/wordsworth/lists   # directory with the two files

allow.json — typed patterns whose full match is not PII of that type. A detection is dropped only when its type matches the key (never across types). Elke regel draagt een reden, en het laden weigert een regel zonder:

{"LOCATION": [
  {"patroon": "(?i)^locatie$",
   "reden": "Zelfstandig naamwoord, geen plaats. 41 van 198 documenten."}
]}

Dit is de enige plek in dit systeem waar een wijziging stilletjes tot minder pseudonimisering leidt. Een kale lijst woorden is een lijst die niemand kan nakijken: een lezer ziet het verschil niet tussen ^gemeente$ en een regel die ongemerkt een achternaam vrijstelt. Geweigerd en niet overgeslagen, want half een lijst toepassen is erger dan geen — dan denkt iedereen dat de regel geldt.

Een sleutel die met _ begint is commentaar en wordt overgeslagen. JSON kent geen commentaar, en een lijst die kan uitleggen waarom hij bestaat is meer waard dan een lijst die dat niet kan.

Getypeerd betekent echt getypeerd. gemeente Gooise Meren wordt door de detector soms als LOCATION en soms als ORGANIZATION gemeld; een regel onder één van beide werkt dan maar de helft van de tijd. Gemeten op 2026-09-20: de regel stond alleen onder ORGANIZATION en 159 voorkomens als LOCATION bleven staan. deny.json — typed patterns that are PII; each match becomes a detection (layer list, score 1.0) on top of what the detectors found:

{"KENTEKEN": ["\\b[A-Z]{2}-\\d{3}-[A-Z]\\b"]}

Een kale string mag hier, want een deny-regel voegt bescherming tóé. In de repo draagt elke regel er toch een reden bij: een patroon zonder uitleg is over een half jaar niet te beoordelen.

Wat er nu in staat: het straatadres. Kerkstraat 12 kwam de pijplijn uit als [LOCATION:…] 12 — de detector levert de straatnaam soms wél en het huisnummer nooit, dus het nummer bleef staan. De regel matcht een woord op een straatachtervoegsel gevolgd door een huisnummer. Achtervoegsels die óók gewone woorden eindigen (ring, baan, pad, hof, park) staan er bewust niet bij: "Verandering 3" is geen adres. Getoetst op Artikel 5, bijlage 3, Postbus 1234 en een straatnaam zonder nummer.

Let op wat dit kost: het evalcorpus zaait het woonadres als gewone tekst en niet als PII, dus op dát corpus telt deze regel als precisieverlies. Of een woonadres PII is, is een uitspraak over de zaak en niet iets wat uit een regex volgt — die vraag ligt bij de eigenaar van het corpus.

Waar de lijsten leven

In de repo (lists/), mee in het image, niet in een ConfigMap. De content-hash staat in elk de-identificatie-auditrecord zodat een document terug te voeren is op de regels die het maakten; een hash die wijst naar iets dat iedereen ter plekke kan wijzigen, is een getal zonder herkomst.

De rem

tests/test_allow_list_veiligheid.py toetst elke allow-regel tegen het evalcorpus, waar bekend is wat er aan PII in zit. Een regel die een ingezaaide waarde onderdrukt, faalt. Dat geldt ook voor de losse woorden van zo'n waarde: het corpus zaait volledige namen ("Hendrik de Vries"), maar de detector levert in de praktijk ook losse achternamen — en een regel ^vries$ is precies het gevaarlijke geval. De eerste versie van die toets keek alleen naar de volledige waarde en liet dat passeren.

Dat is de reden dat een allow-lijst hier mag bestaan. Zonder een corpus waarvan de antwoorden bekend zijn, is elke regel een kwestie van vertrouwen.

Where it applies: the reversible driver applies both lists after detection; the irreversible OpenAnonymiser driver applies the deny list only (the service redacts server-side, so an allow rule cannot un-redact). Suppressions are counted per type in the audit aggregates under suppressed_by_list.

Feedback

Vanaf het scherm. Op de documentpagina is elk token aanklikbaar: klik erop en je meldt dat het geen PII van dat type is. Eronder staat een keuzelijst voor het omgekeerde — "hier is een ADRES gemist". Dat is de weg die een mens neemt; de curl hieronder is dezelfde melding voor wie een script schrijft.

Er gaat nooit een waarde mee, en het formulier heeft daarom geen vrij tekstveld. Dat is geen vergetelheid maar de reden dat deze meldingen veilig in de append-only keten passen. Het eindpunt weigert bovendien een token dat geen [TYPE:hash8] is — anders was de regel te omzeilen door een naam in dat veld te zetten.

Waar de meldingen samenkomen. /console/meldingen — of liever /console/feedback — zet ze bij elkaar: hetzelfde token door meer mensen gemeld staat bovenaan. Het gewicht is het aantal verschillende melders, niet het aantal meldingen: één iemand die tien keer klikt is geen tien mensen, en zonder dat onderscheid is de lijst te vullen door één vasthoudende gebruiker.

Dat scherm toont geen waarden. Welke naam achter een token zit, zie je langs de gewone weg: een grant, een rol, een geauditeerde onthulling. Een overzicht dat de waarde er "even" bij zet is de tweede deur met het grootste bereik -- hij toont ze allemaal tegelijk.

Wat je meldt verandert de lijsten niet vanzelf. Dat blijft een git-wijziging die iemand nakijkt; de melding is de aanleiding, niet de beslissing.

Het bestaande corpus bijwerken

Een lijstwijziging geldt alleen voor wat er daarna binnenkomt. Wat er al in staat, volgt met:

curl -XPOST $API/reprocess -H 'content-type: application/json' \
  -d '{"only_outdated": true}'

only_outdated slaat over wat al onder de huidige lijsten is verwerkt. Dat is geen tijdstempel die je moet onthouden: de lijst-hash staat in elk de-identificatie-auditrecord, dus verandert de lijst, dan is elk document dat nog de oude hash draagt vanzelf achterstallig.

De lus zelf staat in wordsworth/reprocess.py en wordt door het eindpunt én door een Job gebruikt. Dat is geen netheid maar een geleerde les: op 2026-09-20 bouwde ik die lus na in een Job en liet de foutregistratie weg, waardoor een run van 250 documenten geen enkel spoor naliet van wat er misging. Elke mislukking hoort een reprocess_failed-auditrecord op te leveren met de oorzaakketen (ObjectStoreError <- KeyError) en veilige kenmerken — nooit een waarde.

Reken op uren. Reprocess haalt de brontekst opnieuw op, laat hem langs de detector, embedt opnieuw en schrijft de index bij. Op 2026-09-20 kostte dat ongeveer 6 ms per teken — voor 770 documenten (8,6 miljoen tekens) veertien uur, op één core zonder GPU. Draai het als Job, niet als HTTP-verzoek, en niet in de pod die het verkeer bedient: een meting daar legde die pod om (OOM).

Die run moest halverwege onderbroken worden. Zónder only_outdated had de vervolgrun de 200 afgeronde documenten overgedaan — dáárom bestaat de vlag.

De redacted PDF wordt stale. reanonymize herbouwt ook de box map (pdf-redaction); zolang dat niet is gebeurd, antwoordt GET /documents/{id}/redacted.pdf met 409 stale zodra de huidige lijsten afwijken van de lijsten waaronder die box map is gemaakt — ook als het document zelf nog niet opnieuw is verwerkt. Pas na deze run (of een losse reanonymize op dat document) levert het eindpunt weer een PDF.

A reader who spots a false positive or a miss records it against the document:

curl -XPOST $API/documents/<doc>/feedback -H 'content-type: application/json' \
  -d '{"kind":"fp","type":"PERSON","token":"[PERSON:3fa9c2d1]"}'

kind is fp or fn; token is the [TYPE:hash8] pseudonym concerned (for fp). There is no free-text field: feedback can never carry a clear value. The call appends a detection_feedback access event to the document's audit chain and changes nothing else. Turning feedback into a list entry is a reviewed git change; after changing the lists, run POST /reprocess if the already-indexed corpus should follow.