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.tsunder “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.tsand 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
offfor 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 shape | Filter effectiveness |
|---|---|
| Customer text → model | Effective. This is what the mechanism was built for. |
| Customer text + page image → model | Partial. Text is redacted; the image is not. |
| Page image + fixed prompt → model | Ineffective. 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)
| Path | Notes |
|---|---|
routes/convert.ts | Resolves the level via getEffectivePiiFilter and threads piiFilterLevel into the converters below. |
services/smart-cascade-converter.ts | |
services/unpdf-claude-converter.ts | Text-only payload — scrubs extractedText before the prompt is built. Genuinely covered. |
services/verification-prograder.ts | Omitted 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:
| Path | Product |
|---|---|
mcp/tools/audit.ts | Gevell Audit |
mcp/tools/acr.ts | Gevell Report Card |
routes/remediate.ts | Gevell Web |
routes/remediate-v2.ts | Gevell Web |
routes/forms.ts | Gevell Forms |
mcp/tools/url-remediate.ts | Gevell 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:
| Path | Customer text in the payload |
|---|---|
services/alt-text-suggester.ts | surroundingText (~200 chars of page text), filename, current alt |
services/html-remediator.ts | The 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_PROMPTorTRANSCRIBE_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-pctboxes 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 requesteddata-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.
phiis additive, not exclusive. For each text chunk it runs bothDetectPiiEntitiesandDetectPHIand merges the spans. It can therefore never cover fewer categories thanpii. (PreviouslyphicalledDetectPHIonly, so the strictest policy silently lost coverage for categories PHI types generically — e.g. an SSN came back asID, and credentials such asPASSWORD/AWS_ACCESS_KEYwere not detected at all and reached the prompt unredacted.)- Two billable calls at
phi. Each Comprehend call is priced separately, soscrubForAIrecords twocost_ledgerentries atphi— onecomprehend_piiand onecomprehend_phi— versus onecomprehend_piiatpii. Chunks are sized to the smaller 20,000-byteDetectPHIcap 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.
DetectPHIreturns only thePROTECTED_HEALTH_INFORMATIONcategory; it detects no diagnoses or medications. Those requireDetectEntitiesV2(MEDICAL_CONDITION/MEDICATION), which we do not call. Diagnosis and medication text passes through to AI prompts at every level, includingphi. This is acceptable for de-identification: diagnosis and medication are not HIPAA Safe Harbor identifiers. Exposing clinical categories as opt-in viaDetectEntitiesV2(priced higher thanDetectPHI, to be recorded throughrecordCost) 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.
getEffectivePiiFilterreturnsoffon any database error, andscrubForAIpasses a chunk through unredacted if the Comprehend call throws. Both log aterrorso 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.
DetectPiiEntitiestakes 100,000 bytes per call,DetectPHI20,000. Longer text is split into byte chunks; atphithe 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
offat 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:
| Criterion | Status |
|---|---|
Migration adds ai_pii_filter (default off) | Done — migration 300, on both tenants and profiles |
pii-scrubber.ts with Comprehend integration | Done |
| IAM policy for Comprehend in CDK | Done — api-stack.ts, both namespaces |
Cost tracking (comprehend_pii / comprehend_phi) | Done — migration 300 extends the operation_type check |
off has zero performance impact | Done — early return before any client construction |
| Unit tests for redaction logic | Done — pii-scrubber.test.ts |
| Scrubber called in all AI ingestion paths | Withdrawn, not completed (#3463). Never wired beyond three call paths, and ineffective on the largest of them. |
| Team Settings UI exposes the control | Not done — unmerged in #3028 |
| Job detail shows redaction count | Not done — redactionCount is returned by scrubForAI and recorded, but never surfaced in any app |
| Integration test against Comprehend | Not done |
| Security assessment response updated | Not 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.