Skip to content

Redaction Engine

Nabeel Wasif edited this page Sep 29, 2026 · 2 revisions

Redaction Engine

How values are found, how a finding becomes a decision, and how a decision becomes a removal.

Canonical source: docs/ai-engine.md · code: lib/redaction/*, lib/ai/*.

Two ideas run through this whole layer:

  1. The model proposes; it never applies. Nothing a model returns changes a document. It points at text, a person decides, and deterministic code does the removing.
  2. Ask the model only what only a model can answer. A regular expression can tell that a string is an email address. It cannot tell whether Apple is a fruit, a company or a customer. Spending tokens on the first question is waste; spending them on the second is the point.

1. The pipeline

flowchart TD
    N["Normalized document<br/>(pages streamed, text only)"] --> D1["1 · DETERMINISTIC DETECTORS<br/>email, phone, Luhn-checked cards, valid SSNs, IBANs,<br/>credentials, label-gated dates and accounts<br/>free · reproducible · no tokens"]
    D1 -- "candidates + an already-found list" --> D2["2 · CLASSIFY<br/>type, language, likely density<br/>one ~2 KB sample"]
    D2 --> D3["3 · CONTEXTUAL DETECTION<br/>names, prose addresses, roles, health, confidential<br/>~6 KB chunks through the throttle"]
    D3 --> D4["4 · VERIFY<br/>is this labelled number really an account?<br/>one batched call for shaky candidates"]
    D4 --> D5["5 · EXPAND<br/>occurrences 2..n of every judged value<br/>local string search · no tokens"]
    D5 --> P["deduplicate → preset filter → persist as SUGGESTIONS"]
    P --> H{"a person accepts or rejects<br/>(or a rule decides, or Hush proposes and a person approves)"}
    H -- accepted --> R["the exporter removes it"]
    H -- "rejected, or never decided" --> K["it stays in the document"]
Loading

Steps 1 and 5 cost nothing. Steps 2 to 4 are the only ones that spend tokens, and step 3 is told not to repeat what pattern matching already found. Images and scanned pages also get a vision pass, and spreadsheets a column pass (§4).

Cost, concretely

For a document mentioning john@example.com seventeen times:

Approach Model calls
Naive (ask per occurrence) 17
Ask once per chunk ~1 per chunk containing it
This pipeline 0: the regex matched, and step 5 found the rest

For a name the regex cannot recognise, the model answers once, and the other sixteen occurrences are found by local search. Occurrence 2..n costs a scan, not a request, and cannot come back with a different answer than occurrence 1.

Whether this claim holds is now measurable: pnpm bench:models runs a model with and without the deterministic pass on a labelled corpus. See Testing and Benchmarks.


2. Deterministic detectors

lib/redaction/detectors.ts. Each detector is a pattern plus, where it matters, a validator. The validators keep the list from being noise:

Category Guard
financial Luhn check: an order number is not a payment card
government-id structural validity: area ≠ 000/666, group ≠ 00
bank-account a bare 11-digit number is an account only if labelled as one; IBANs by shape
date-of-birth a date is a birth date only after DOB / born / etc.
customer-id ACME-88231 is a part number unless labelled Customer ID
url only links carrying a token or a private path; docs links are noise
api-key provider credential formats and token shapes
phone at least nine digits, so Room 12 at 9am does not match
email by shape

Overlaps resolve in favour of the higher-confidence detector, so a card number is reported once as a card and not also as a phone number. The patterns are module-level with the g flag, so lastIndex is reset before each use. A stateful regex silently returning different results on its second call is a classic, very quiet bug, and a test covers it.

The deterministic baseline on the labelled corpus's test split is 20.1% covered recall at 94.1% precision. It finds no names, which are the model's job. See Testing and Benchmarks.


3. Talking to the model

lib/ai/providers/*, lib/ai/gateway.ts, lib/ai/prompts/*, lib/ai/schemas/*

  • Any of 25 providers. Calls go to whichever provider and model the operator configured and verified: the AI Gateway (the backward-compatible default), an official SDK adapter, a cloud platform, a local server (Ollama, LM Studio, llama.cpp), an OpenAI-compatible URL, or a ChatGPT sign-in. See AI Providers.

  • Structured output only. Every call passes a Zod schema, and output that does not validate is discarded. No prose is parsed, and there is no "find the JSON in the response" path.

  • Located, not trusted. The model returns the text it saw, never an offset, and the application finds that text itself:

    const located = locateInPage(chunk.text, detection.text)
    if (!located) continue   // the model named text that is not there: drop it

    A model that paraphrases, corrects a typo or invents a plausible value cannot inject it into the document. Whitespace may differ (extracted text is unreliable about it); the characters may not. CI checks this rule with a scripted provider (tests/analysis-orchestration.test.ts).

  • Capabilities are checked before the call. Setup verifies structured output and image input and saves a declaration (AI_MODEL_CAPABILITIES) bound to the provider, model and destination. An unverified or unsupported model returns unsupported instead of being called.

  • Failure is contained, and its reason is kept. A provider error returns a result carrying skipped and a reason, never an exception. A run that fell short writes document.ai.degraded, and the usage panel says so. A document reviewed on pattern matching alone must never look identical to one the model genuinely found nothing in.

  • Paced, then retried. lib/services/throttle.ts sits in front of every call. ANONIFY_AI_CONCURRENCY bounds calls in flight across every document (it used to be per document, so six documents meant twenty-four concurrent calls). 429, 5xx and timeouts retry with jitter and honour Retry-After; 401, 402 and 403 never retry.

  • A budget. ANONIFY_AI_DAILY_SPEND_USD, estimated from AiUsage rows and per-model prices: at 80% calls drop to one at a time; at 100% the contextual pass is skipped for the rest of the UTC day and said to be skipped.

  • Prompts are never logged. They contain the document. Logs carry model, task, duration, token counts and an error category.

With no model at all, the product still works: deterministic detection, manual redaction, search, rules and export all run.


4. What is asked, per task

Task Sees Returns
classify ~2 KB opening sample type, language, density
detect one ~6 KB chunk + values already found verbatim spans, category, confidence, global
verify low-confidence candidates + 80 chars of context each one verdict per candidate, batched
columns headers + 5 sample values per column which columns are sensitive, and why
image the pixels + OCR text for context regions as integers on a 0–1000 grid
assistant whatever Hush's tools return, after consent tool calls and replies (Hush Assistant)
  • Spreadsheets are judged by column, not by cell. Headers plus a handful of samples is enough to judge a column, and "this column is sensitive" is far cheaper and closer to the decision a reviewer actually makes than ten thousand answers. It becomes one column-level suggestion.
  • Faces are asked for as the whole head, with generous bounds. Covering only the eyes does not reliably anonymise anyone, and slightly too large is recoverable where slightly too small is a leak.
  • The 0–1000 grid (since 1.6.2) is the convention vision models such as Qwen3-VL and Gemini are trained on, so they answer in it unprompted. Requiring integers means a model that answers in 0–1 fractions fails validation instead of having its box read as a speck in the corner. The application scales once. An over-long region explanation is shortened rather than discarding the region.
  • Preset filtering applies to model proposals (categoryAllowed) as well as to the detectors. A preset that turned off account numbers and then accepted the model's account-number suggestions would narrow nothing. See Redaction Presets.

5. Suggestion → decision → removal

Detection ─▶ Redaction { status: "suggested", source: "ai" }
                 │
     reviewer ───┼──▶ accepted ──▶ counts at export (with a method)
                 └──▶ rejected ──▶ stays in the file, forever
Rule ──────────▶ Redaction { status: "accepted", source: "rule", ruleId }
Manual ────────▶ Redaction { status: "accepted", source: "user" }
Hush ──(Approve)▶ a rule, or accepted/rejected groups, or manual redactions

The exporter reads only accepted. Nothing else can reach the file.

  • Rules ("redact every EMP-\d{5}") search the normalized document on the server, write one accepted redaction per match, and never consult a model. Deleting a rule removes exactly what it created. Rules can be literal or RE2 RegEx, at document, batch or global scope. See Search and Rules.
  • Occurrence groups. The inspector groups suggestions by category|normalizedText, so seventeen "John Smith"s are one decision.
  • Accept all operates on every filtered suggestion but leaves ignored ones alone (fixed in 1.5.0).
  • Undo/redo keeps up to 50 snapshots of the redaction map and saves only the redactions it changed (fixed in 1.5.0). The snapshot materialises Immer's draft first (current()), because spreading the draft stores references that the next mutation edits. That was a real bug, caught by a test.

6. Applying, per format

lib/redaction/apply.ts turns accepted redactions into format-specific instructions. The interesting part is mapping offsets onto structure. A span's id is its source address (word/header1.xml#p3r1, ppt/slides/slide2.xml#p0r1, text#1042, part:0.1/body#248), so the exporter parses the address instead of searching for the value.

  • DOCX / PPTX: page offsets map onto the runs that produced them. A value straddling runs (Word splits at every formatting change) gives each run exactly the slice it contributed.
  • PDF: offsets map onto glyph boxes measured from the font's own character widths, reaching below the baseline for descenders, padded 1.5 pt. The redacted page is rasterised at 2× with the boxes burned in. (The width-based placement is the 1.5.0 fix for GHSA-h3hv-rpqw-7p84; earlier analyses are covered a whole run at a time.)
  • XLSX / CSV / TSV: addresses, not offsets: cell, row or column. Formulas referencing a redacted address are dropped.
  • Images: geometry directly, or OCR span boxes for a text redaction.
  • TXT / RTF / EML: offsets into the source through the atom map; EML also reaches headers and filenames.

Alongside the precise edits, every export carries a value sweep: the accepted strings are removed wherever else they appear in the container. That catches a name in a DOCX header the editor never displayed, or an email in a hidden worksheet.

What replaces the value is the redaction's method (mask, pseudonymize, tokenize, encrypt), gated by category and by whether the target can carry text. One method applies per value, strongest wins. See Export and Anonymisation.


7. Verification

lib/redaction/validation.ts. The export is re-opened and read the way an adversary would:

Format Read as
PDF extracted text via pdf.js; a raw byte scan with whitespace, parens and escapes stripped; no /Annots or /Redact; redacted pages re-extract with zero spans
DOCX / PPTX every .xml and .rels part, raw; the package still opens
XLSX every cell of every sheet, hidden included, plus every part and the shared-string table
CSV / TSV re-parsed as a grid, every cell
TXT / RTF the exact text; RTF re-parsed (it must still open)
EML re-parsed by two parsers, ours and postal-mime; attachment substitutions checked by checksum
Image pixel sampling in the test suites, and metadata
Rebuilt MBOX split again by the upload's scanner: count, bytes per message, separator lines

Any accepted value still present throws ExportVerificationError. The artifact is not saved, no link is issued, and the client is told the export was refused. The operator's log records how many values leaked; the client is told nothing that would help locate them.

Values shorter than four characters are not asserted on, because they collide by coincidence and a check that fails at random is a check people learn to ignore. Strings the pipeline itself authored (surrogates, ciphertexts) are excised from the haystack first, because a long random ciphertext can contain a short accepted value by chance, and none of them is derived from the value in a way that could reproduce it.


8. The export report

A second artifact, downloadable beside the file: counts removed by category, the style and method each was applied with, what the reviewer rejected or never decided, the preset (and a note if it narrowed the search), attachment dispositions, and both checksums. It carries counts, never content, and that is checked (assertReportOmitsValues) before the report is stored. See Security Model §6.


9. What this engine does not claim

  • It does not find everything. The reviewer is the control, and accepting is a deliberate act.
  • Confidence is a hint, not a probability. It is shown as a band and a percentage for triage, never as a verdict.
  • Verification proves the absence of the accepted strings, not of the information. "The CEO of the company on Elm Street" still identifies someone after the name is gone. That judgement belongs to the reviewer.
  • Detection is English-shaped (#43).

See also

Clone this wiki locally