Tokenize PII and get it back
A working reversible round trip — the config, the environment variable, a real request pair, both ledger rows, and the one trade you are making against redact.
On this page
Goal: replace detected email addresses in supported request fields before the provider call, then restore this request’s minted placeholders in a buffered answer.
The example uses the default on_error: open: failed inspection can forward original bytes.
For a requirement to withhold content when inspection cannot finish, configure acknowledged
on_error: closed and verify its failure cases as well as the successful round trip.
The trade in one sentence: reversibility buys you a restored answer and costs you a vault holding
real values for the life of the request. redact has nothing to leak because it kept nothing.
1. Configure the forward verb only
There is no re-identify binding to write. Bind tokenize on the input phase and the reverse leg runs
on the output phase by itself — a config naming action: reidentify is
refused at boot.
guardrails:
enabled: true
default_profile: standard
local_budget_ms: 50
vault:
key_env: TT_VAULT_KEY
guards:
- id: pii_tokenize_in
category: pii
phases: [input]
detectors: [pii.email, pii.card]
mode: enforce
action: tokenize
rule_ref: POL-PII-REID-1
profiles:
standard: [pii_tokenize_in]
Validate before you arm it. guardrails explain loads the config, so a bad file fails here rather
than at boot:
$ tokentriage guardrails explain --binding pii_tokenize_in --config guarded.yaml
BINDING pii_tokenize_in
category: pii
phases: input
mode: enforce
on_error: open
LADDER — the verb each severity resolves to (fall-down included)
SEVERITY ACTION REVERSIBLE THIS BUILD CAN DO IT
low tokenize yes yes
2. Start it with the key in the environment
$ export TT_VAULT_KEY="$(head -c 32 /dev/urandom | base64)"
$ tokentriage run -c guarded.yaml
3. Send a request
$ curl -s http://127.0.0.1:4790/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-…' \
-d '{"model":"gpt-4o-mini","messages":[
{"role":"user","content":"my email is [email protected]"}]}'
What the upstream received:
"my email is [TTsbjk:EMAIL_1]"
What your client received:
{"choices":[{"message":{"role":"assistant",
"content":"Sure — I have noted my email is [email protected] and will follow up there."}}]}
The TTsbjk part contains a fresh random nonce drawn for each request; collisions remain possible.
Isolation comes from the request-local minted mappings. Within one request the same value always
maps to the same placeholder:
"ping [email protected] and [email protected] again"
⇒ "ping [TTj5xz:EMAIL_1] and [TTj5xz:EMAIL_1] again"
4. Read both legs on the ledger
Two kind: guardrail rows, same req_id. The forward leg names the detector and its finding; the
reverse leg has no detector, because none runs on it:
{"v":1,"kind":"guardrail","req_id":"tt_o2fjhgarhtsbm2xk67ekejljdi",
"binding":"pii_tokenize_in","phase":"input","detector":"pii.email",
"detector_version":"pii.email-v1","action":"tokenize",
"findings":[{"entity":"EMAIL","spans":[{"field":"messages[0].content","off":12,"len":15}],
"raw_score":0.85,"confidence":{"basis":"reported","point":0.85}}],
"posture":"open","mode":"enforce"}
{"v":1,"kind":"guardrail","req_id":"tt_o2fjhgarhtsbm2xk67ekejljdi",
"binding":"pii_tokenize_in","phase":"output","detector":"","detector_version":"",
"action":"reidentify","posture":"open","mode":"enforce"}
The reverse leg carries the forward binding’s id, which is what lets you join the two halves of one round trip without inventing a correlation field.
5. If you want a realistic surrogate instead
Swap the verb. Same config, same round trip, different bytes upstream:
action: pseudonymize
UPSTREAM SAW: my email is [email protected]
Both verbs are equally reversible; pick pseudonymize when a bracketed marker would confuse the
model and tokenize when a realistic-looking value would.
What can go wrong
- The model mangles the placeholder. Exact match only, so the mangled marker reaches your client verbatim. Ugly, and the safe failure.
- The response came from the cache. The reverse leg never runs on replayed bytes — restoring another request’s body is a cross-request leak, not a no-op.
- You are streaming. The reverse leg is the buffered output gate’s; it is not implemented on the stream lane.
- You expected
fuzzy_reidto help. It is accepted and read by nothing.