// Case Study · A compliance product measuring its own worst failure
Signet
PII Redaction Proxy · AI API Traffic · Solo Build
Signet is an HTTP proxy that sits between an application and an LLM provider. It scans request bodies for personal data, replaces what it finds with deterministic tokens, forwards the redacted payload upstream, and writes an audit record. Point your OpenAI client at a different base URL and the rest of your code does not change. It is a working side project with real deployed traffic and no paying customers.
// 01 · Why a proxy
An SDK wrapper only protects the calls that use it, and the call that leaks is the one someone wrote in a hurry with the raw client. A middleware library has the same hole plus a per-language port. A proxy is the only shape where the redaction cannot be bypassed by forgetting to use it, because it sits on the network path rather than in the call site.
The cost is that it becomes a hard dependency in the request path, which is why the failure modes below matter more than the feature list.
It runs on Hono, deployed to Fly.io in a single region, us-east, on one shared-CPU 256 MB instance kept warm. Not edge. There is no WAF in front of it and no load test behind it, and both are honest gaps rather than oversights I would defend.

// 02 · Detection, and what it misses
Detection is deterministic pattern matching plus checksum validators plus a bundled first-name list. There is no model in the detection path: no NER, no ML, no LLM. That was evaluated and rejected on latency, since a model costs 50 to 200 ms per call against a budget measured in single-digit milliseconds.
Precision comes from validators that run after each match rather than from cleverer regexes. Luhn on card numbers, mod-97 on IBANs, area rules on SSNs, a structural check on IPv6. JSON key names are matched separately in constant time, which is what catches a bare first name or a house number that no pattern would flag. Name heuristics only accept a standalone capitalised pair if the first token is in a bundled first-name list, and that gate is the only thing keeping "Customer Service" and "Project Alpha" out. Normalisation and zero-width stripping run before any pattern, so invisible-character splitting does not defeat them.
The known miss is a personal name in free prose with no salutation and no common first name. It is hardcoded in the test suite with a written rationale, so it cannot silently grow, and the generated compliance document tells the customer about it. Closing it means either lowering the name gate, which over-redacts brand and place names, or adding a model, which the roadmap always said should run outside the hot path.
The false positives are just as real and the artifact below shows them rather than hiding them: a country name tokenized as a name, any ISO date in a wide range read as a date of birth, and place names built from given names. The one I did not expect is in amber on the image: the same phone number in two formats produces two different tokens, because the HMAC covers the exact matched string with no normalisation first. That undercuts part of the reason determinism was chosen at all.

// 03 · Tokens that are not a vault
Tokens are a truncated HMAC-SHA256 over the tenant id, the type, and the value. Not reversible encryption, because that carries recoverable personal data into the provider’s payload and into the logs. Not a random-mapping vault, because a random token needs a stateful write per distinct value and a read back for any join, in the hot path of every request. An HMAC is a pure function: no state, no coordination, no lookup.
Determinism is the feature. The same customer produces the same token every time, so you can count distinct people, dedupe, and join across requests without ever holding a raw value.
The cost is an equality leak, and it is documented rather than hidden: anyone reading one tenant’s redacted logs can tell which fields shared a value. The tenant id is inside the HMAC input, so nothing correlates across tenants. The hash is also truncated to 64 bits, which is worth knowing before anyone claims uniqueness.
Reversal exists only for the top plan, through an encrypted map under a per-tenant key derived from a different master than the one that makes tokens. Compromising the tokenization secret does not decrypt maps and compromising the encryption key does not forge tokens. One endpoint reads it, tenant-scoped, and unknown ids and other tenants’ ids return an identical 404 so it cannot be used to confirm existence. Every reveal and every denial writes an audit row. Lower plans never populate the map at all, so their redaction is irreversible by construction rather than by policy.
Key rotation is a runbook, not code. Rotating changes every future token for the same value, so historical logs stop joining with new ones, and there is no re-tokenization tooling.
// 04 · The bypass
Two independently reasonable decisions combined into a fail-open. The scanner skipped prototype-pollution keys as a defence. The redactor short-circuited and returned the object untouched when the scanner reported zero matches, as an optimisation. Together, a body whose only personal data sat under one of those keys produced zero matches, took the short-circuit, and went upstream unredacted, logged as clean.
That is worse than a plain leak. The data reaches the provider and the audit log affirmatively certifies the call contained no personal data. The product’s own output becomes a document that is confidently, verifiably false.
In a compliance product a false negative is not a missed detection, it is a manufactured false attestation. That framing is why the suite has a recall battery at all, why it hardcodes its one known miss, and why the generated document tells the customer where detection is best-effort.
The other hard one was finalising the audit record for a streaming response. The obvious implementation puts the write in a transform stream’s flush, which is never called when the writable side errors, so an upstream failure mid-stream left the row stuck on its placeholder forever. Silent, invisible in testing, and precisely the failure that corrupts the artifact the product exists to produce. The fix routes all three terminal paths, normal completion, upstream error, and client cancel, into one guarded finaliser that records why the stream ended.
// 05 · What the numbers do and do not say
The recall figure is 95.2%, twenty of twenty-one seeded values across nine request shapes in three provider formats, measured by asserting the seeded string does not survive into the serialized upstream payload. Recall on the structured-JSON case, which is the dominant real-world shape, is 100%. It is a self-designed test set, and self-designed test sets flatter their authors, so that belongs in the same sentence as the number.
The latency figure is a local micro-benchmark of scan and redact CPU only, excluding network, auth, and database. It says the detection layer is not the bottleneck. It says nothing about what a customer experiences.
There is no uptime figure, no production latency percentiles, no throughput measurement, and no detection rate against any external corpus. A metrics endpoint exists but requires a token that is unset, so nothing scrapes it. Production has 69 log rows across two tenants and no traffic since June, which is real but far too small to characterize anything.
What I would rebuild: the audit write is fire-and-forget, chosen to save 30 to 100 ms of median latency. For a product whose sole deliverable is the record, trading the record for latency is backwards, and a database outage means requests succeed while audit records silently vanish. A bounded write-behind queue with backpressure and a visible drop counter keeps the latency win without losing records. That is the first thing I would reverse.
// 06 · Compliance, stated accurately
Signet generates a GDPR Article 30 processing record and an AI usage log from real proxied traffic. It produces the records. It does not make anyone compliant, and it is not legal advice.
The Article 30 record is the stronger of the two. The article enumerates a fixed set of fields and the generator populates each from operational data. Two gaps remain: it covers one processing activity where a controller’s register must cover all of them, and the categories of personal data are populated from what was actually detected, so that field is a floor rather than an inventory.
The AI usage log supports EU AI Act record-keeping obligations and is explicitly not Annex IV technical documentation. Annex IV wants the risk management system, the training and validation methodology, human oversight measures, and the declaration of conformity, and an operational usage log covers none of that. The generated document says so itself, in the artifact the customer receives, which is the right place for it.
The generator refuses to run at all if the organisation name or contact is a placeholder like "Personal" or "TBD". An official-looking record naming "Personal" as the data controller is worse than no record.
None of the templates have been reviewed by a lawyer or a DPO. What exists is a self-conducted security battery. Saying that plainly costs nothing and is the only defensible position.
// 07 · Known broken
The most useful section for anyone evaluating how I work, because almost nobody writes it.
- No self-serve per-subject erasure. Only full-account deletion and retention expiry exist, so a customer routing EU end-user data cannot honour one individual’s deletion request without deleting their whole account. Deliberately deferred and documented honestly. The fix is cheap precisely because tokenization is deterministic: tokenize the target identifier with the tenant key, find the rows containing that token, delete them.
- A verified-email column that is decorative. Every production tenant reads false, including OAuth accounts, because only one rarely-completed flow sets it. It looks like a security gate and is wired to nothing, which is how a future engineer ships either an outage or a false sense of security.
- No production observability. Metrics endpoint disabled, nothing scraping, no uptime monitor, no load test, no baselines.
- No independent security review or penetration test. The battery is self-conducted, and a self-conducted battery is a starting point, not a credential.
- Email lookup is exact-string, so three spellings of one inbox become three accounts.
- No published terms of service, DPA, or SLA, for a product sold on compliance.
Built and run solo
Personal data in the redaction artifact is invented; the tokens, types, and confidence values are unmodified output of the shipped code. The recall and latency figures are from the project’s own test suite and a local benchmark, not from production traffic.
