This post is one boundary of a five-boundary architecture. The map, and how the pieces work together, is in Deterministic controls for probabilistic systems.
Every team that pipes customer text into an LLM eventually writes a redaction step. A roster of client names, patterns for emails and phones, placeholders in, prompt out.
The step runs for months and never reports a problem.
That silence is worth interrogating. When a redactor misses, the text goes out anyway, and from the outside a miss looks exactly like a clean pass.
A miss and a clean pass both hand back a string and wave the request through. You find out which one you had at the incident review.
Why does a redaction check pass while leaking?
Because of how the check usually gets built. The obvious design reuses the matcher.
You have a pattern that finds client names, so you redact with it, and then you verify the output by running the same pattern again. It finds nothing, and the check goes green.
It will always go green. The check can only look for what the redactor already replaced.
Anything the redactor failed to match the first time, the checker fails to match too, because they are the same code.
This is the default design, and it survives code review because nothing about it looks wrong. Reusing a matcher you already trust reads as discipline.
The gate this post hands over started there too, guarding a pipeline whose output files can never carry a client name. One shared regex, and every run passed.
What exposed it was adversarial testing, feeding the checker text the redactor was known to miss.
northwind_robotics and Dr Vasquez sailed through, because neither sits on a word boundary the strict pattern could see.
Nothing in that design can ever produce a failure, which means a pass carries no information. The check is running, and it is not checking anything.
What does an honest redaction gate look like?
It splits the job across two matchers that are deliberately different, and allowed to be wrong in opposite directions.
The redactor stays precise. Word boundaries, strict patterns. Its output has to remain a working document.
A redactor aggressive enough to never miss would destroy the text it was hired to protect. It would turn index.js into index[domain] and get switched off within a week.
The checker gets paranoid. It runs the strict patterns, then looser ones over normalized copies of the text where case, punctuation, and the usual obfuscations have collapsed.
northwind_robotics and NorthwindRobotics both reduce to the roster entry. ada [at] northwind [dot] example reduces to an address the strict email pattern can finally see.
The checker may only be wrong in one direction. Flagging clean text costs a minute of reading a refusal. Missing means a client's name is already in a vendor's context window.
And when the checker finds a survivor, it throws. Nothing gets written, sent, or logged, and the wrapped call never runs.
A warning would have left the text on disk.
What mistakes should you skip?
All three of these were found by running the thing, and each one is now a regression test.
The half redaction that read as clean. A roster term sat inside an email address. Substitution ran first, so ada@northwind.example became ada@[client].example.
The address broke open. The local part of a real person's address survived, and the fragment matched no pattern at all.
Two fixes. Structured patterns now claim the whole token before roster terms run, and a residue detector treats a placeholder welded into an address as proof of a half redaction.
The refusal that leaks. An exception crosses a boundary too. A refusal lands in Sentry, Datadog, or a CI transcript.
Stopping an identifier at the model only to write it into the error tracker moves the leak instead of preventing it.
So the thrown error withholds the matched value by default. Class, line, column and length are what you act on at 2am, and they are always there.
The gate that can hang. A 100,000 character run of a single repeated character took 7.9 seconds to scan.
Loose patterns with required separators backtrack across every start position when the separator never arrives.
Every pattern run is now length capped, and the suite holds the full detector set to a two second budget on deliberately hostile inputs.
That matters because a slow gate does not stay installed. The first engineer it hangs on will rip it out, and the removal never gets revisited.
The suite carries the thesis as a rule. Every detector needs a true positive, a near miss that stays quiet, and one case where the redactor provably misses and the gate catches.
A detector without that third case has never demonstrated it can fire.
How do you run it on your own text?
The repo is public, MIT, zero dependencies, Node 20 or newer. Clone it and the tests run with no install step and no network.
git clone https://github.com/derrtaderr/redaction-gate.git
cd redaction-gate
npm test
node example/guard-an-llm-call.jsPoint it at your own names by writing a roster, which is a JSON file you own. A new pattern is a new record, never a code change.
echo "ticket filed under northwind_robotics by Dr Vasquez" \
| node bin/redaction-gate.js check --config example/roster.json
# redaction-gate: REFUSING. 2 identifier(s) survived redaction in stdin.
# exit 2Exit code 2 makes it a CI gate or a pre-commit hook as is.
In code, guard wraps any function that carries text out of your process. The highest leverage place to put it is an agent's tool table.
An agent with send_email and post_slack has more exits than its prompt. Wrapping the table guards every door with one move.
$ echo "Ticket filed under northwind_robotics/renewal by Dr Vasquez, \
reachable at ada [at] northwind [dot] example." \
| node bin/redaction-gate.js check --config example/roster.json
redaction-gate: REFUSING. 4 identifier(s) survived redaction in stdin.
stdin:1:20 client (18 chars)
stdin:1:50 person (10 chars)
stdin:1:75 residue (17 chars)
stdin:1:84 residue (22 chars)
$ echo $?
2Note what the refusal does not print. Class, line, column and length, and never the value. An exception message travels to your error tracker and your CI log, so a gate that named what it caught would leak it through the door it was built to watch.
What does this not do?
It ships no model and no NER, so person and company names come from the roster you maintain.
It over-flags on purpose, and the remedy is the allowlist rather than a looser gate. It leans ASCII and English, so Cyrillic lookalikes will pass.
It produces the durable record a SOC 2 or GDPR review asks for without being a certification of anything.
FAQ
Why refuse instead of warn? A warning is a log line next to text that already went out. The refusal happens before the write, which is the only moment the text is still yours.
What about a name nobody put in the roster? It will not be found, with a narrow exception for honorific forms like Dr Vasquez. The roster is a living file you keep current.
Does the compliance log contain the sensitive values? Hashes, counts and positions go in. Values never do. A log carrying the leaked value has moved the leak rather than recorded it.
Is this only about PII? The transferable lesson is about gates. Wherever a fixer and a checker share their logic, the checker can only confirm what the fixer already handled.
Until the two halves diverge, the green light carries no information.