The model is a witness: live-testing a fail-closed PII gateway.
cordon sits between an application and an LLM and makes one promise: raw PII never reaches the model. Placeholders go up, real values come back restored, and if detection ever fails, the request is blocked, not forwarded. Its hardening release had 150 green tests. We still refused to deploy until we'd booted real processes, attacked them over live HTTP, killed one mid-suite, and let the model itself testify.
cordon is the newest tool in our Own Your Stack line, a drop-in, Anthropic- and OpenAI-compatible proxy for teams whose users type emails, card numbers, and account details into an LLM-backed product. It detects PII, payment data, and secrets with checksum validators (Luhn for cards, mod-97 for IBANs) so a version string never gets redacted as a credit card, swaps each value for a placeholder before the request leaves your perimeter, and restores the real values in the reply, including mid-stream, token by token. The audit log is an append-only hash chain that records counts and types, never values.
The design commitment that shapes everything else: cordon fails closed. If detection throws, the request dies with a 422 and the upstream is never called. A redaction gateway that fails open is worse than no gateway, because it converts "we might leak" into "we leak precisely when our guard is broken," while everyone believes the guard is up.
This release hardened three places where a gateway like this can quietly stop keeping its promise. Each one is a class of bug worth knowing about even if you never run our tool.
Three ways a redaction gateway can lie to you
A pseudonym mode with no secret behind it. cordon has an optional mode where the same value maps to the same placeholder across requests, useful when the model needs to correlate "this customer" across turns without knowing who the customer is. Those stable tokens are HMACs, and an HMAC with an empty or guessable key is a dictionary attack waiting politely: anyone who can guess candidate values can reproduce the tokens and read your "redacted" transcripts like a codebook. The fix is blunt on purpose: with pseudonyms enabled and no adequate secret, the server refuses to boot. A tenant that switches the mode on at runtime without a secret gets a 422 on every request, upstream never called. A security feature that can be misconfigured into a leak should decline to run in that configuration, not log a warning and carry on.
Placeholder collision. Reversible mode used to mint counter tokens like <EMAIL_1>. Now imagine a caller whose own text contains that literal string, documentation about the format, a pasted transcript of a previous reply, or an adversarial prompt doing it deliberately. On the way back, the restore step would rewrite the caller's own placeholder-shaped text into a real email address pulled from the vault. That's the gateway manufacturing a leak out of thin air. The fix: every token now carries a per-request nonce, <EMAIL_7F3A2B_1>, so a caller's literal can never collide with a minted token.
Policy that reverts on restart. Per-tenant policy lived in memory. Set a tenant to the stricter irreversible mode, restart the container three weeks later, and the tenant silently falls back to the global default, the kind of regression nobody notices until an audit does. The fix is an optional file-backed policy store, loaded before the server accepts traffic, rewritten on every admin change, and off by default so the single-container, no-datastore deployment stays exactly that.
Exit codes are behavior too
All of that was unit-tested, property-tested (fast-check drives byte soup, zero-width smuggling, and full-width digit evasion at the detector until something survives, nothing did), and integration-tested. 150 tests, green. Here's the thing: a green suite is a claim about your code. It is not yet a claim about your process. "Refuses to boot" is a promise about exit codes, and you test exit codes by spawning the real server and watching it die.
PASS A: boot REFUSED (exit 1) with pseudonyms + no secret
PASS A: error names TENANT_SECRET
PASS B: boots + /healthz with pseudonyms + strong secret
PASS C: dev escape hatch boots (explicit, never in prod)
PASS D: control — pseudonyms off + no secret still boots
Four spawn configurations, four observed outcomes, including the control case proving the guard doesn't over-fire. Cheap to write, and it tests the one thing unit tests structurally can't: what the operating system sees.
Then attack it over real HTTP
Next, a live instance fronting an echo upstream that records every byte it receives, so every assertion about "what the model saw" is checked against what an actual server actually received, headers and all. Thirty-eight checks. The ones that earn their keep:
PASS upstream saw NONCE'd placeholder (975B0A)
PASS per-request nonce differs (5029F3 vs 4AE061)
PASS literal <EMAIL_1> returned unchanged
PASS real email still restored alongside
PASS fail-closed: 422, upstream call count = 0
PASS 'locked' strip policy SURVIVED restart
PASS audit chain verifies; log holds NO raw values
Three details matter. The nonce assertions are read off the wire, two identical requests, two different nonces observed at the upstream, which is the collision fix doing its job in production shape rather than in a function call. The fail-closed assertion checks both halves: the client got a 422 and the upstream's call counter stayed at zero, asserting the error without asserting the absence of the forward is how fail-open bugs hide. And halfway through the suite we kill the gateway process and start a new one: the tenant that was set to strip mode is still in strip mode afterwards. Persistence claims deserve a process death, not a mock.
The model is a witness
Reversible mode has a satisfying end-to-end proof: ask the model to repeat the contact line back, and the reply arrives with the real email restored, which can only happen if the substitution and the vault both worked. But strip mode has a better one. In strip mode nothing is restored, so when we ran the play against a real Claude model, the reply came back reading email [EMAIL] about card [CREDIT_CARD]. The model echoed the placeholder because the placeholder is all it ever received. No log inspection, no trust in our own instrumentation, the model's own output is the receipt.
We earned this habit the hard way. An earlier cordon build passed a synthetic streaming harness (send text, reassemble deltas, compare strings) and then failed against the real client, because real extended-thinking responses stream a thinking block at index 0 before any text block, and the re-identifier assumed every delta was text. The real client's strict SSE parser threw; our harness had never produced that shape. The fix was straightforward; the lesson is permanent, and it rhymes with what we've written about install paths: validate a proxy with the real client's traffic shapes, not with the requests you find convenient to synthesize. Today the test stub streams thinking-first on purpose, and the live battery asserts no text delta ever lands on a thinking block.
The deploy earns the same scrutiny
Shipping this to our own box goes through a one-click, human-dispatched workflow on a self-hosted runner, no hands on the machine. It builds the image on the box from a read-only deploy key, syncs the compose definition behind a drift guard that shows the diff and refuses to overwrite anything outside cordon's own service block (a deploy for one service should be incapable of silently reverting another's live config), health-gates the new container, smoke-tests the redaction plane and the audit chain, and rolls back, container and config both, if any of that fails.
The pipeline paid for itself on its second run: the box's stored registry credential had expired, and an expired token doesn't degrade to anonymous, it 401s even public image pulls. The workflow now authenticates with a fresh credential when one exists and otherwise logs out so public pulls proceed anonymously. A deploy pipeline is a distributed system too; it gets the same fail-closed-with-a-recovery-path treatment as the service it ships.
The boundary we drew on purpose
One decision worth stating because most write-ups wouldn't: our own coding agents do not route through cordon. We tested that configuration deliberately, and it taught us where the tool's edge is. A redaction gateway's job is to hide values from the model; a coding or ops agent's job is to work with those values, the IPs, emails, and tokens in a config file aren't incidental PII, they're the task. Route an agent's tool output through redaction and the model reasons over placeholders it can't act on. The two jobs are mutually exclusive by construction, and no amount of tuning changes that.
So cordon is scoped to where it's genuinely load-bearing: user-facing products where a customer's email, card, or account details have no business reaching a third-party model, and where "we redact" needs to be a verifiable property, not a slide. Knowing exactly where your tool stops being the right tool is a feature of the tool.
What transfers
If you're building or buying anything that promises to fail closed, the checklist we'd hand you:
- Watch it refuse. Assert the error and the upstream call count. A 4xx with a forwarded request behind it is fail-open wearing fail-closed's clothes.
- Exit codes are behavior. "Refuses to start when misconfigured" is testable only by starting it, misconfigured, and watching.
- Kill the process. Any durability claim, policy, state, audit, gets proven by a restart, not a unit test.
- Feed it its own output format. If your system substitutes tokens into text, someone will eventually send you text containing those tokens. Decide what happens before they do.
- Let the model testify. In an irreversible mode, the model's echo of a placeholder is the cleanest proof it never saw the value.
- Give the deploy the same rigor. Gate on health, smoke the actual guarantees, and keep a rollback that restores config as well as code.
193 checks between "the tests are green" and "it's running in production," most of them written in an afternoon, several of them impossible to express as unit tests at all. That gap is where gateways quietly stop keeping their promises. We'd rather live in it than discover it.
We build and audit the layer between your product and the model: redaction gateways, guardrails, secret brokers, and the live tests that prove they do what the README says. If your compliance story currently rests on "the suite is green," we can help you find out what it rests on after a kill -9.