Ga naar inhoud

Runbook: the redacted PDF

GET /documents/{id}/redacted.pdf and POST /documents/{id}/reveal.pdf return the document as a PDF with layout, images and pages intact, but with every detected value removed from the page — not just covered — and a typed label ([PERSOON 1]) painted on its box. ADR-0005 D11 is amended narrowly for this: a redacted rendering of the source PDF is a core output; other formats (docx and the like) stay out.

Preconditions

Only in reversible mode (WORDSWORTH_REVERSIBLE=true, a key provider and mapping store configured): the box map is built by transiently decrypting the document's own pseudonyms to locate them in the PDF, so a document anonymised with the irreversible driver — which keeps no mapping — has nothing to locate and never gets a box map. GET .../redacted.pdf then answers 409.

Get the redacted PDF

curl $API/documents/<doc>/redacted.pdf -o redacted.pdf

Cached under redacted/<doc>/<render_version>/<boxmap_hash> in object storage — a second call reads the cached object rather than re-rendering. render_version is a constant in pdf_render.py, bumped by hand with every change to that module, so a renderer change (a size fix, a box fix) is never served from a render an older renderer made — the new version simply misses the cache and renders again. Objects under the old redacted/<doc>/<boxmap_hash> prefix (from before this change) and under a superseded render_version are never read again; they only cost storage, not correctness. Clear them by hand when reclaiming space — nothing reads them. Responses:

Status Meaning
200 the redacted PDF
404 unknown document
409 "no box map yet..." not processed in reversible mode, or the last run hit value-not-located
409 "stale" the current detection lists differ from the ones the box map was made under

A stale response means the lists moved (git change, redeploy) since this document's box map was built — not necessarily that the document itself is behind. reanonymize (see reversible backfill and detection lists) rebuilds the box map under the current lists; the next call then serves a fresh PDF.

Reveal a granted type, as a PDF

curl -XPOST $API/documents/<doc>/reveal.pdf -H 'content-type: application/json' \
  -d '{"grant_id":"<grant_id>","types":["PERSON"]}' -o reveal.pdf

The same grant and the same gates as /reveal — a document that has no box map yet also answers 409 here. The granted type is drawn in clear, straight from the original page (nothing is decrypted to render it: the box for that type is simply left unpainted, so the original pixels and the original invisible text pass through); every other type keeps its box and label. The result is never cached or stored — a cached reveal would be a store of clear PII — and is audited exactly like /reveal: a deanonymize record with view: "pdf", the resolved types, and no clear value.

The value-not-located audit record

Detection, profiling, the index text and the box map all read the one PDF extraction (positioned_text, pypdfium2, text_source pdfium-v1, pdf-redaction-locate) — pypdf is not used to extract PDF text any more. A space is inserted where pdfium itself emits none between two characters that sit far enough apart on a line; the inserted space has no character box, so nothing is drawn for it.

A value is located whole-word in that positioned text. A line break acts as a word separator (each run of it becomes one such space), so the last word of a line is never glued to the first word of the next; a value's own words are matched with optional whitespace between them, so a value detected across a line break — carrying a literal break of its own — still matches. Matching otherwise follows the value's normalisation rule (pdf-redaction-match): case-insensitive for the casefold types (PERSON, ORGANIZATION, LOCATION, EMAIL), whitespace-insensitive for POSTCODE, and exact for every other type.

The count check: a token's count in the anonymised text must be at most the whole-word match count of its value in the positioned text. When it is not, the run writes a value-not-located audit record (the PII type and the two counts, never the value) and persists no box map — a partly-redacted PDF is never produced. The endpoint then answers 409 until a later run (after a text/lists change) locates it. POST /reprocess (see reversible backfill) lists every value-not-located outcome it hits in its problems response, by document, PII type and the counts (located of in-text, e.g. 0 of 1 located).

The count-by-type report

wordsworth.value_not_located_report.by_type(problems) groups a reprocess run's value-not-located outcomes (its problems dict) by PII type: per type, the number of documents and the located/occurrence totals summed across them — nothing else. It is a pure function over the problems dict the reprocess endpoint already returns, so it runs offline on synthetic input and, unchanged, over the real response of a live run (see reversible backfill). It never carries a value, a page text or a context window — only the PII type and integers.

When replacement itself survives: AnonymizationInvariantError

A value-not-located miss means a value could not be found again in the PDF to box it — the text itself is clean. A detected value that is still present in the anonymised text after replacement is a different, more serious failure: the pseudonymiser refuses to emit it and raises AnonymizationInvariantError with code waarde-overleefde-vervanging (pseudonymizer.py). It carries only safe features of the survivor — label, lengte, in_bron, na_vervanging, woorden, alnum — never the value itself, in the error, its str/repr, or the audit record. It is not retried: is_transient() (retry.py) returns False for it, so a document raising it counts as failed, never retryable — retrying a deterministic code bug only burns GLiNER time to reach the same conclusion again.

Character boxes: tight, or loose when tight is empty

Every box comes from pdf_text.char_box: pdfium's tight character box, or its loose box (the font's full bounds) when the tight one is degenerate. The Word-exported PDFs of the Gooise Meren Woo dossier report a tight box 0.01 pt high for every character (2026-10-26). Box maps built from those covered nothing, and the first "redacted" PDFs showed every name; those cached renders were deleted by hand the same day — bumping render_version now makes that unnecessary, since the fix itself changes the cache key.

The render refuses a box with no surface (DegenerateBoxError); the two PDF endpoints answer 409 "box map unusable ..." until reanonymize rebuilds the box map with real boxes. A redacted PDF that hides nothing is never served.

How it is built

Every page is rasterised at 300 dpi; the rectangles are painted into the raster, so the text underneath is genuinely gone, not covered. The painted raster is then JPEG-encoded (quality 80) before the PDF is rebuilt from scratch with reportlab — flate, reportlab's default, barely shrinks a 300 dpi page raster and one document came out at 151 MB under it; JPEG keeps a page under the 1 MB/page budget tests/test_pdf_render.py::test_page_size_is_bounded enforces. Encoding happens after the boxes are painted, so the codec never sees anything but the box colour under a redaction. The PDF then carries vector [PERSOON 1]-style labels on the boxes (legible.to_legible's numbering — the same as ?view=legible on /documents/{id}/anonymized) plus an invisible text layer for everything that is not redacted, so search and copy keep working for the rest of the page. Nothing is copied from the source document: no metadata, no annotations, no outline.

Scanned documents use the existing OCR text layer (object_key repointed by the OCR recovery step) — there is no separate OCR path for redaction.

Not decided here

Whether and how the console embeds these two views is a separate, undecided question (see the pdf-redaction proposal's open questions) — this runbook covers the two HTTP endpoints only.