Skip to content

PII/PHI Filter — Coverage and Limits

WITHDRAWN 2026-09-22 (#3463). The filter is off, and the user-facing setting has been removed.

§2, §5 and §6 below are SUPERSEDED. Read this correction first.

§1 is right and is the durable part: Comprehend is a text API and cannot inspect an image. But §2 gets the conversion cascade wrong. It lists services/smart-cascade-converter.ts under “Covered — text paths” with the note “Text-only payload,” and that is false. Every model call in that lane sends the page itself — PDF bytes, or a PNG rendered from it. The extracted text the filter scrubbed is used there for routing, blank-page detection and coverage measurement, reaching a model only as two small derived fragments. The largest path §2 counts as covered was the one where redaction did least.

The two genuinely covered paths were services/unpdf-claude-converter.ts and the ACR pre-grader (services/verification-prograder.ts, which §2 omits entirely). Both are text-only prompts, and on both the filter worked as described. Withdrawing it gives up real redaction there — an accepted loss, not a no-op.

The filter was never wired into the preserve (flyer-tagger, tag-in-place) lane. In production it was off for every profile and for the tenant.

We removed it because a control covering three call paths, presented in the UI as an account-wide “AI data protection” level, misdescribed its own scope — and the scope claim was the part customers would act on. Redacting the image channel is what a whole-product claim would require; that is a different and much larger piece of work, and it is the precondition for reconsidering this.

The Comprehend integration survives behind AI_PII_FILTER_ENABLED (default off), so it is recoverable — either for the two text-only paths alone, or as part of covering the image channel.

Issue: #3015 · Setting: ai_pii_filter (off / pii / phi) on tenants and profiles · Mechanism: pii = AWS Comprehend DetectPiiEntities; phi = both DetectPiiEntities and Comprehend Medical DetectPHI, merged (see §4.1)

This document records exactly which AI calls the filter covers and which it does not, and why. It exists because the setting’s name promises more than the mechanism can deliver on every path, and an assessor reading “PII filtering supported” needs the scope stated rather than assumed.

Read alongside ai-risk-register.md entry 1.


1. The governing constraint

Comprehend is a text API. It accepts a UTF-8 string and returns entity spans to redact. It cannot inspect an image.

That single fact determines everything below. Gevell’s AI calls fall into three shapes, and the filter’s usefulness differs completely across them:

Call shapeFilter effectiveness
Customer text → modelEffective. This is what the mechanism was built for.
Customer text + page image → modelPartial. Text is redacted; the image is not.
Page image + fixed prompt → modelIneffective. There is no customer text to redact.

A page screenshot of an advising worksheet carries the student’s name in its pixels. Comprehend never sees it. No amount of plumbing changes this.


2. Coverage today

Covered — text paths (filter works as intended)

PathNotes
routes/convert.tsResolves the level via getEffectivePiiFilter and threads piiFilterLevel into the converters below.
services/smart-cascade-converter.tsText-only payload. WRONG — see the correction at the top. This lane prompts the model with the page (PDF bytes / rendered PNG). Scrubbed text is used for routing and coverage, not as the prompt.
services/unpdf-claude-converter.tsText-only payload — scrubs extractedText before the prompt is built. Genuinely covered.
services/verification-prograder.tsOmitted when this was written. The ACR/WCAG pre-grader, reached from routes/acr.ts and mcp/tools/acr.ts. Text-only prompt. Genuinely covered.

These three are the entire covered surface. The filter is correct here: the payload is text, and the text is redacted. Superseded: two of these were genuinely covered; the cascade was not.

Not covered — other product ingestion paths

Text-bearing, so the filter would work, but it is not wired in:

PathProduct
mcp/tools/audit.tsGevell Audit
mcp/tools/acr.tsGevell Report Card
routes/remediate.tsGevell Web
routes/remediate-v2.tsGevell Web
routes/forms.tsGevell Forms
mcp/tools/url-remediate.tsGevell Web

This is the largest and most fixable gap. #3015’s acceptance criteria call for “all AI ingestion paths (PDF conversion, audit, ACR, remediation, forms)”; only PDF conversion is done.

Not covered — hybrid paths (text + image)

Scrubbing the text portion is worthwhile but leaves the image unprotected:

PathCustomer text in the payload
services/alt-text-suggester.tssurroundingText (~200 chars of page text), filename, current alt
services/html-remediator.tsThe HTML content itself, plus inline base64 images

Not covered — vision-only paths (filter cannot help)

Detailed in §3. Wiring the scrubber into these would redact a static prompt while the page image passes through untouched — producing a filter that reports itself active while protecting nothing. Do not wire the scrubber into these to close a checklist item. That trades a known gap for a false assurance, which is worse.


3. The four vision-only pipelines

Each sends a rendered page image plus a prompt. None is on the default conversion path — all four are fallback or second-pass paths — but each receives a full page screenshot when it fires.

3.1 services/quality-gate.ts — page-description fallback

Renders a page to PNG and requests a comprehensive description.

  • Prompt: DESCRIBE_PROMPT or TRANSCRIBE_PROMPT — module-level constants. No customer text is interpolated.
  • Fires when: the vision pipeline produced suspiciously thin output (for example, table headers with no data rows). Runs after chunk assembly, before post-processing.
  • Filter effect if wired: none. There is no customer text in the payload.

3.2 services/cover-text-describer.ts — cover title transcription

Sends the page-1 PNG with the literal prompt Transcribe the cover page text.

  • Prompt: a fixed string. No interpolation.
  • Fires when: a designed cover bakes its title into the artwork, leaving the page-1 text layer empty.
  • Filter effect if wired: none, and the path is self-defeating for redaction by design — its purpose is to recover text from an image. Redacting its input is meaningless; redacting its output would remove the title the feature exists to produce.

3.3 services/region-refiner.ts — bounding-box tightening

Sends a page screenshot with PROMPT(alt, current).

  • Prompt: interpolates derived alt text (the model’s own earlier output describing the target figure) and four integer bbox percentages.
  • Fires when: a second pass over vision-tier data-region-pct boxes that came back too generous — including captions, body text, or an adjacent figure.
  • Filter effect if wired: marginal, and not zero. Derived alt text is second-order customer content: an alt string such as “Photo of student J— receiving an award” carries the name forward. Redacting it would, however, degrade the refiner’s accuracy, since the alt text is precisely how the model identifies which figure to bound.

3.4 services/image-region-recovery.ts — vector-graphic recovery

Sends a page screenshot with PROMPT(pageAlt).

  • Prompt: interpolates derived alt text describing the graphic to locate.
  • Fires when: a narrow conjunction — the PDF has zero raster XObjects (a pure-vector graphic such as Word SmartArt), the cheap vision model emitted an empty <img> without the requested data-region-pct, and the page has a usable text layer.
  • Filter effect if wired: same as §3.3 — marginal, non-zero, and in tension with the feature’s accuracy.

4.1 Level semantics — phi is a superset of pii (#3249)

Corrects the original claim that phi was “all PII + HIPAA-relevant: MRN, diagnosis, medication, etc.” — neither half was true before #3249.

  • phi is additive, not exclusive. For each text chunk it runs both DetectPiiEntities and DetectPHI and merges the spans. It can therefore never cover fewer categories than pii. (Previously phi called DetectPHI only, so the strictest policy silently lost coverage for categories PHI types generically — e.g. an SSN came back as ID, and credentials such as PASSWORD / AWS_ACCESS_KEY were not detected at all and reached the prompt unredacted.)
  • Two billable calls at phi. Each Comprehend call is priced separately, so scrubForAI records two cost_ledger entries at phi — one comprehend_pii and one comprehend_phi — versus one comprehend_pii at pii. Chunks are sized to the smaller 20,000-byte DetectPHI cap so both calls stay within their limits.
  • Label preference on overlap. When a span is flagged by both APIs the more specific Comprehend PII label wins, so an SSN stays [REDACTED-SSN] rather than the generic [REDACTED-PHI-ID].
  • Clinical content is out of scope — honestly. DetectPHI returns only the PROTECTED_HEALTH_INFORMATION category; it detects no diagnoses or medications. Those require DetectEntitiesV2 (MEDICAL_CONDITION / MEDICATION), which we do not call. Diagnosis and medication text passes through to AI prompts at every level, including phi. This is acceptable for de-identification: diagnosis and medication are not HIPAA Safe Harbor identifiers. Exposing clinical categories as opt-in via DetectEntitiesV2 (priced higher than DetectPHI, to be recorded through recordCost) is a separate product decision tracked in #3249.

4. Other limits that apply even on covered paths

  • English only. Comprehend detects PII/PHI in English text. Non-English documents pass through unredacted on every path, including the covered ones. This is a silent pass-through, not an error.
  • Fail-open by design, in two places. getEffectivePiiFilter returns off on any database error, and scrubForAI passes a chunk through unredacted if the Comprehend call throws. Both log at error so Grafana alerts fire. The trade-off is deliberate — a Comprehend outage degrades protection rather than breaking every conversion — but it means an active filter is not a guarantee that a given request was redacted.
  • Chunking. DetectPiiEntities takes 100,000 bytes per call, DetectPHI 20,000. Longer text is split into byte chunks; at phi the smaller 20,000-byte cap is used since both APIs run on each chunk. An entity straddling a chunk boundary may be missed.
  • Prompt-only. The original document in R2 is never modified. Redaction applies to the AI prompt payload alone.
  • Defaults to off at both tenant and user level, and the stricter of the two wins. As of this writing the settings UI is unmerged (#3028), so no customer can turn the filter on through the product.

5. Outstanding work on #3015

Superseded — #3015 was closed as withdrawn (#3463), not completed. The table is kept as a record of where the work stopped, not as a plan.

Measured against the issue’s acceptance criteria:

CriterionStatus
Migration adds ai_pii_filter (default off)Done — migration 300, on both tenants and profiles
pii-scrubber.ts with Comprehend integrationDone
IAM policy for Comprehend in CDKDone — api-stack.ts, both namespaces
Cost tracking (comprehend_pii / comprehend_phi)Done — migration 300 extends the operation_type check
off has zero performance impactDone — early return before any client construction
Unit tests for redaction logicDone — pii-scrubber.test.ts
Scrubber called in all AI ingestion pathsWithdrawn, not completed (#3463). Never wired beyond three call paths, and ineffective on the largest of them.
Team Settings UI exposes the controlNot done — unmerged in #3028
Job detail shows redaction countNot done — redactionCount is returned by scrubForAI and recorded, but never surfaced in any app
Integration test against ComprehendNot done
Security assessment response updatedNot done — and should use §6 below rather than the phrasing in the issue

6. What to say in an assessment

⚠️ The paragraph this section used to supply is RETRACTED (#3463). Do not send it to anyone. It began “Gevell supports team- and user-level PII/PHI filtering, applied to document text before it is sent to any AI model” — which is no longer true in any part. It is not reproduced here, because the failure mode for a block of paste-ready copy is that someone pastes it.

The accurate answer to “do you filter PII before AI ingestion?” is now:

Gevell does not filter or redact personal information out of the content sent to AI models, and does not offer a setting that claims to. A text-scrubbing filter existed through 2026-09 and was withdrawn: it covered three of the product’s AI call paths and was ineffective on the largest of them, because that pipeline sends the page image to the model rather than the extracted text the filter cleaned. We judged an accurate description of our coverage more useful to you than a partial control described as protection.

Content protection rests instead on controls that apply to every path: all AI processing runs through Amazon Bedrock under a Zero Data Retention configuration, so customer content is not retained by, or used to train, the model provider; content is isolated per tenant; and an account- or tenant-level Disable AI services setting routes documents through deterministic processing only, for customers who need content to reach no model at all.

Two things to keep straight when answering follow-ups:

  • ZDR is not a filter. It bounds what happens to content after it is sent; it does not reduce what is sent. Describe them separately — conflating them is how the previous paragraph came to overstate the position.
  • “Disable AI services” is the real control for a customer who cannot send content to a model. Note that it is not uniformly graceful: some routes fall back to deterministic processing, others decline the operation. Say which if asked about a specific feature.

If a customer requires pre-ingestion redaction as a contractual condition, the honest answer is that we do not have it today, and that providing it would require redacting the image channel — see the correction at the top of this document. Do not offer the withdrawn filter as a roadmap item without a decision on #3463 first.